quikdown
Version:
Small, safe, bidirectional Markdown parser and editor with streaming, undo/redo, rich fences, MCP tools, and a standalone airgapped build. Zero runtime deps. Browser and Node.js.
6,651 lines • 285 kB
JavaScript
/**
* Quikdown Editor - Drop-in Markdown Parser
* @version 1.2.22
* @license BSD-2-Clause
* @copyright DeftIO 2025
*/
'use strict';
/**
* quikdown_classify — Shared line-classification utilities
* ═════════════════════════════════════════════════════════
*
* Pure functions for classifying markdown lines. Used by both the main
* parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
* lives in one place.
*
* All functions operate on a **trimmed** line (caller must trim).
* None use regexes with nested quantifiers — every check is either a
* simple regex or a linear scan, so there is zero ReDoS risk.
*/
/**
* Full CommonMark HR check: three or more identical characters from
* {-, *, _} with optional interspersed whitespace.
*
* Examples that return true: ---, ***, ___, ----, - - -, * * *, _ _ _
* Examples that return false: --, - text, ---text, mixed -_*, empty
*
* Algorithm (O(n), single pass, no backtracking):
* 1. Strip all whitespace
* 2. Verify length >= 3
* 3. First char must be -, *, or _
* 4. Every remaining char must equal the first
*
* @param {string} trimmed The line, already trimmed
* @returns {boolean}
*/
function isHRLine(trimmed) {
if (trimmed.length < 3) return false;
// Strip whitespace via linear scan
let stripped = '';
for (let i = 0; i < trimmed.length; i++) {
const ch = trimmed[i];
if (ch !== ' ' && ch !== '\t') stripped += ch;
}
if (stripped.length < 3) return false;
const ch = stripped[0];
if (ch !== '-' && ch !== '*' && ch !== '_') return false;
for (let i = 1; i < stripped.length; i++) {
if (stripped[i] !== ch) return false;
}
return true;
}
/**
* Check if a trimmed line opens a code fence.
* Returns { char, len, lang } if it does, or null otherwise.
*
* A fence opener is 3+ identical backticks or tildes at the start of a line,
* optionally followed by a language tag.
*
* @param {string} trimmed The line, already trimmed
* @returns {{ char: string, len: number, lang: string } | null}
*/
function fenceOpen(trimmed) {
if (trimmed.length < 3) return null;
const ch = trimmed[0];
if (ch !== '`' && ch !== '~') return null;
let len = 1;
while (len < trimmed.length && trimmed[len] === ch) len++;
if (len < 3) return null;
const lang = trimmed.slice(len).trim();
return { char: ch, len, lang };
}
/**
* Check if a trimmed line closes an open fence.
* The closing fence must use the same character, be at least as long,
* and have no content after (optional trailing whitespace only).
*
* @param {string} trimmed The line, already trimmed
* @param {string} openChar The fence character ('`' or '~')
* @param {number} openLen Length of the opening fence marker
* @returns {boolean}
*/
function isFenceClose(trimmed, openChar, openLen) {
if (trimmed.length < openLen) return false;
let len = 0;
while (len < trimmed.length && trimmed[len] === openChar) len++;
if (len < openLen) return false;
// Rest must be whitespace only
for (let i = len; i < trimmed.length; i++) {
if (trimmed[i] !== ' ' && trimmed[i] !== '\t') return false;
}
return true;
}
/**
* Classify a content line into a category string.
* Order matters: HR before list-ul (since `- - -` looks like a list start).
*
* @param {string} trimmed The line, already trimmed
* @returns {string} One of: 'heading', 'hr', 'list-ol', 'list-ul',
* 'blockquote', 'table', 'paragraph'
*/
function classifyLine(trimmed) {
if (/^#{1,6}\s/.test(trimmed)) return 'heading';
if (isHRLine(trimmed)) return 'hr';
if (/^\d+\.\s/.test(trimmed)) return 'list-ol';
if (/^[-*+]\s/.test(trimmed)) return 'list-ul';
if (/^>/.test(trimmed)) return 'blockquote';
if (/^\|/.test(trimmed)) return 'table';
return 'paragraph';
}
/**
* Heuristic: does a line look like a markdown table row?
* @param {string} line The line (trimmed or untrimmed)
* @returns {boolean}
*/
function looksLikeTableRow(line) {
return line.includes('|');
}
/**
* quikdown — A compact, scanner-based markdown parser
* ════════════════════════════════════════════════════
*
* Architecture overview (v1.2.8 — lexer rewrite)
* ───────────────────────────────────────────────
* Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
* type (headings, blockquotes, HR, lists, tables) and each inline format
* (bold, italic, links, …) was handled by its own global regex applied
* sequentially to the full document string. That worked but made the code
* hard to extend and debug — a new construct meant adding another regex
* pass, and ordering bugs between passes were subtle.
*
* Starting in v1.2.8 the parser uses a **line-scanning** approach for
* block detection and a **per-block inline pass** for formatting:
*
* ┌─────────────────────────────────────────────────────────┐
* │ Phase 1 — Code + Escape Extraction │
* │ 1a. Fenced code blocks (``` / ~~~) → §CB§ placeholders│
* │ 1b. Escaped backticks (\`) → §BE§ placeholders │
* │ 1c. Inline code spans (`…`) → §IC§ placeholders │
* │ Cannot cross newlines (scoped to single line). │
* │ 1d. Remaining backslash escapes (\* \_ etc.) → §BE§ │
* │ Only ASCII punctuation is escapable; \a stays. │
* ├─────────────────────────────────────────────────────────┤
* │ Phase 2 — HTML Escaping │
* │ Escape &, <, >, ", ' in the remaining text to prevent │
* │ XSS. (Skipped when allow_unsafe_html is true.) │
* ├─────────────────────────────────────────────────────────┤
* │ Phase 3 — Block Scanning │
* │ Walk the text **line by line**. At each line, the │
* │ scanner checks (in order): │
* │ • table rows (|) │
* │ • headings (#) │
* │ • HR (---) │
* │ • blockquotes (>) │
* │ • list items (-, *, +, 1.) │
* │ • code-block placeholder (§CB…§) │
* │ • paragraph text (everything else) │
* │ │
* │ Block text is run through the **inline formatter** │
* │ which handles bold, italic, strikethrough, links, │
* │ images, and autolinks. │
* │ │
* │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
* │ (single \n → <br>) are handled here too. │
* ├─────────────────────────────────────────────────────────┤
* │ Phase 4 — Code Restoration │
* │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
* │ / <code> HTML, applying the fence_plugin if present. │
* └─────────────────────────────────────────────────────────┘
*
* Why this design?
* • Single pass over lines for block identification — no re-scanning.
* • Each block type is a clearly separated branch, easy to add new ones.
* • Inline formatting is confined to block text — can't accidentally
* match across block boundaries or inside HTML tags.
* • Code extraction still uses a simple regex (it's one pattern, not a
* chain) because the §-placeholder approach is proven and simple.
*
* @param {string} markdown The markdown source text
* @param {Object} options Configuration (see below)
* @returns {string} Rendered HTML
*/
// ────────────────────────────────────────────────────────────────────
// Constants
// ────────────────────────────────────────────────────────────────────
/** Build-time version stamp (injected by tools/updateVersion) */
const quikdownVersion = '1.2.22';
/** CSS class prefix used for all generated elements */
const CLASS_PREFIX = 'quikdown-';
/** Placeholder sigils — chosen to be extremely unlikely in real text */
const PLACEHOLDER_CB = '§CB'; // fenced code blocks
const PLACEHOLDER_IC = '§IC'; // inline code spans
const PLACEHOLDER_HT = '§HT'; // safe HTML tags (limited mode)
const PLACEHOLDER_BE = '§BE'; // backslash escapes
const PLACEHOLDER_NL = '§NL§'; // newlines inside indented code (restored in Phase 4)
/** Attributes whose values need URL sanitization */
const URL_ATTRIBUTES = { href:1, src:1, action:1, formaction:1 };
/** HTML entity escape map */
const ESC_MAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
// ────────────────────────────────────────────────────────────────────
// Style definitions
// ────────────────────────────────────────────────────────────────────
/**
* Inline styles for every element quikdown can emit.
* When `inline_styles: true` these are injected as style="…" attributes.
* When `inline_styles: false` (default) we use class="quikdown-<tag>"
* and these same values are emitted by `quikdown.emitStyles()`.
*/
const QUIKDOWN_STYLES = {
h1: 'font-size:2em;margin:.67em 0;text-align:left',
h2: 'font-size:1.5em;margin:.83em 0',
h3: 'font-size:1.25em;margin:1em 0',
h4: 'font-size:1em;margin:1.33em 0',
h5: 'font-size:.875em;margin:1.67em 0',
h6: 'font-size:.85em;margin:2em 0',
pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
table: 'border-collapse:collapse;width:100%;margin:1em 0',
th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
td: 'border:1px solid #ddd;padding:8px;text-align:left',
hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
img: 'max-width:100%;height:auto',
a: 'color:#06c;text-decoration:underline',
strong: 'font-weight:bold',
em: 'font-style:italic',
del: 'text-decoration:line-through',
ul: 'margin:.5em 0;padding-left:2em',
ol: 'margin:.5em 0;padding-left:2em',
li: 'margin:.25em 0',
'task-item': 'list-style:none',
'task-checkbox': 'margin-right:.5em',
'alert': 'padding:1em;margin:1em 0;border-left:4px solid #0969da;border-radius:4px;background:#ddf4ff',
'alert-title': 'font-weight:600;margin:0 0 .4em',
'alert-note': 'border-left-color:#0969da;background:#ddf4ff',
'alert-tip': 'border-left-color:#1a7f37;background:#dafbe1',
'alert-important': 'border-left-color:#8250df;background:#fbefff',
'alert-warning': 'border-left-color:#9a6700;background:#fff8c5',
'alert-caution': 'border-left-color:#cf222e;background:#ffebe9',
sup: 'font-size:.75em;vertical-align:super;line-height:0',
'footnotes': 'margin-top:2em;font-size:.9em',
'footnote-backref': 'text-decoration:none;margin-left:.25em'
};
// ────────────────────────────────────────────────────────────────────
// Attribute factory
// ────────────────────────────────────────────────────────────────────
/**
* Creates a `getAttr(tag, additionalStyle?)` helper that returns
* either a class="…" or style="…" attribute string depending on mode.
*
* @param {boolean} inline_styles True → emit style="…"; false → class="…"
* @param {Object} styles The QUIKDOWN_STYLES map
* @returns {Function}
*/
function createGetAttr(inline_styles, styles) {
return function(tag, additionalStyle = '') {
if (inline_styles) {
let style = styles[tag];
if (!style && !additionalStyle) return '';
// When adding alignment that conflicts with the tag's default,
// strip the default text-align first.
if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
style = style.replace(/text-align:[^;]+;?/, '').trim();
/* istanbul ignore next */
if (style && !style.endsWith(';')) style += ';';
}
/* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
return ` style="${fullStyle}"`;
} else {
const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
if (additionalStyle) {
return `${classAttr} style="${additionalStyle}"`;
}
return classAttr;
}
};
}
// ────────────────────────────────────────────────────────────────────
// Link destination + heading slug + blockquote helpers
// ────────────────────────────────────────────────────────────────────
/**
* Parse a markdown link/image destination: url with optional "title" or 'title'.
* Supports angle-bracket form: <url>
* @returns {{ url: string, title: string|null }}
*/
function parseLinkDestination(raw) {
if (raw === undefined || raw === null || raw === '') return { url: '', title: null };
const dblQuote = raw.match(/^(.*)\s+(?:"([^"]*)"|"([^&]*?)")\s*$/);
if (dblQuote) {
return { url: dblQuote[1].replace(/\s+$/, ''), title: dblQuote[2] ?? dblQuote[3] };
}
const sglQuote = raw.match(/^(.*)\s+(?:'([^']*)'|'([^&]*?)')\s*$/);
if (sglQuote) {
return { url: sglQuote[1].replace(/\s+$/, ''), title: sglQuote[2] ?? sglQuote[3] };
}
if (raw.startsWith('<') && raw.endsWith('>')) {
return { url: raw.slice(4, -4), title: null };
}
return { url: raw, title: null };
}
/** Build a URL-safe slug from heading text (inline markdown stripped). */
function headingSlug(text) {
return text
.replace(/[*_`~]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
}
/** Return a unique slug, suffixing -1, -2, … on duplicates. */
function uniqueSlug(base, counts) {
const n = counts.get(base) || 0;
counts.set(base, n + 1);
return n === 0 ? base : `${base}-${n}`;
}
/**
* Strip trailing punctuation from an autolinked URL.
* Handles balanced parentheses (e.g. Wikipedia URLs).
* @param {string} url The matched URL text
* @returns {{ url: string, trailing: string }}
*/
function stripTrailingPunctuation(url) {
let trailing = '';
const punct = /[.,;:!?)]/;
while (url.length > 0 && punct.test(url[url.length - 1])) {
const ch = url[url.length - 1];
if (ch === ')') {
const opens = (url.match(/\(/g) || []).length;
const closes = (url.match(/\)/g) || []).length;
if (opens >= closes) break; // balanced — ) is part of URL
}
trailing = ch + trailing;
url = url.slice(0, -1);
}
return { url, trailing };
}
/**
* Count leading blockquote depth on an HTML-escaped line (> markers).
* @returns {{ depth: number, content: string }}
*/
function parseBlockquoteLine(line) {
let depth = 0;
let pos = 0;
while (pos < line.length && line.startsWith('>', pos)) {
pos += 4;
depth++;
if (line[pos] === ' ') pos++;
}
return { depth, content: line.slice(pos) };
}
/**
* Check if a line breaks lazy blockquote continuation.
* @param {string} line HTML-escaped line text
* @returns {boolean}
*/
function isLazyContinuationBreaker(line) {
const trimmed = line.trim();
if (trimmed === '') return true; // blank line
if (/^#{1,6}\s/.test(trimmed)) return true; // heading
if (isHRLine(trimmed)) return true; // HR
/* istanbul ignore next -- defensive: > lines are caught by parseBlockquoteLine first */
if (/^>/.test(trimmed)) return true; // new blockquote
if (/^[-*+]\s/.test(trimmed)) return true; // unordered list
if (/^\d+\.\s/.test(trimmed)) return true; // ordered list
if (trimmed.startsWith('|')) return true; // table row
if (trimmed.startsWith(PLACEHOLDER_CB)) return true; // code block placeholder
return false;
}
/**
* Base inline formatting patterns shared between the main pass and
* footnotes rendering. Defined once at module level to avoid
* recreating regex objects on every call.
*/
const BASE_INLINE_PATTERNS = [
[/\*\*(.+?)\*\*/g, 'strong'],
[/__(.+?)__/g, 'strong'],
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
[/(?<![A-Za-z0-9_])_(?![_\s])(.+?)(?<![\s_])_(?![A-Za-z0-9_])/g, 'em'],
[/~~(.+?)~~/g, 'del'],
[/`([^`\n]+)`/g, 'code']
];
/** GFM alert type labels */
const ALERT_LABELS = {
NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important',
WARNING: 'Warning', CAUTION: 'Caution'
};
/** Render nested blockquotes from a run of parsed lines. */
function renderNestedBlockquotes(items, getAttr, dataQd) {
// ── GFM alert detection ──
// Check if the first item's content matches [!TYPE]
/* istanbul ignore next -- depth is always 1 for outermost blockquote */
const alertMatch = items.length > 0 && items[0].depth === 1
? items[0].content.trim().match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i)
: null;
let html = '';
const stack = [];
const useInlineStyles = getAttr('blockquote').includes('style=');
for (let idx = 0; idx < items.length; idx++) {
const { depth, content } = items[idx];
/* istanbul ignore next -- depth is always >= 1 from parseBlockquoteLine gate */
if (depth <= 0) continue;
// Skip the [!TYPE] marker line — we'll render it as a title
if (alertMatch && idx === 0) {
const alertType = alertMatch[1].toUpperCase();
const typeLower = alertType.toLowerCase();
if (useInlineStyles) {
const baseStyle = QUIKDOWN_STYLES['alert'];
const typeStyle = QUIKDOWN_STYLES['alert-' + typeLower];
/* istanbul ignore next -- typeStyle is always defined for valid alert types */
const merged = typeStyle ? `${baseStyle};${typeStyle}` : baseStyle;
html += `<div style="${merged}"${dataQd('>')}>`;
} else {
html += `<div class="${CLASS_PREFIX}alert ${CLASS_PREFIX}alert-${typeLower}"${dataQd('>')}>`;
}
// Title
const label = ALERT_LABELS[alertType];
if (useInlineStyles) {
html += `<p style="${QUIKDOWN_STYLES['alert-title']}">${label}</p>`;
} else {
html += `<p class="${CLASS_PREFIX}alert-title">${label}</p>`;
}
stack.push('alert');
continue;
}
while (stack.length > depth) {
const tag = stack.pop();
/* istanbul ignore next -- alert closing uses </div>, blockquote uses </blockquote> */
html += tag === 'alert' ? '</div>' : '</blockquote>';
}
while (stack.length < depth) {
/* istanbul ignore next -- defensive: alert div already opened at depth 0 */
if (stack.length === 0 && alertMatch) {
stack.push('alert');
} else {
html += `<blockquote${getAttr('blockquote')}${dataQd('>')}>`;
stack.push('blockquote');
}
}
html += content;
if (idx < items.length - 1) html += '\n';
}
while (stack.length > 0) {
const tag = stack.pop();
html += tag === 'alert' ? '</div>' : '</blockquote>';
}
return html.trimEnd();
}
/** True when a line is part of a 4-space (or tab) indented code block. */
function isIndentedCodeLine(line) {
if (line.length === 0) return false;
const m = line.match(/^([ \t]+)(.*)$/);
if (!m) return false;
const spaceEquiv = m[1].replace(/\t/g, ' ').length;
if (spaceEquiv < 4) return false;
const content = m[2];
if (/^[-*+]\s/.test(content) || /^\d+\.\s/.test(content) || /^>/.test(content) || /^#{1,6}\s/.test(content)) {
return false;
}
return true;
}
/** Strip one indent level (4 spaces or one tab) from a code line. */
function stripCodeIndent(line) {
if (line.startsWith(' ')) return line.slice(4);
if (line[0] === '\t') return line.slice(1);
const m = line.match(/^[ \t]+(.*)$/);
/* istanbul ignore next -- isIndentedCodeLine guarantees leading whitespace */
return m ? m[1] : line;
}
/** List item: optional indent, a bullet or ordinal marker, whitespace, content. */
const LIST_ITEM_RE = /^(\s*)([*\-+]|\d+\.)\s+(.+)$/;
/**
* True when an indented, non-blank line is wrapped text belonging to the
* list item above it (issue #36). Lines that start a block construct of
* their own (fence, blockquote, table row, heading, HR, or already-rendered
* HTML) are excluded so they keep their existing rendering.
*/
function isListContinuation(line) {
const m = line.match(/^[ \t]+(\S.*)$/);
if (!m) return false;
const t = m[1];
if (/^(```|~~~|>|>|\||#{1,6}(\s|$)|<)/.test(t)) return false;
if (/^([-*_])\1{2,}\s*$/.test(t)) return false;
return true;
}
/**
* processIndentedCodeBlocks — line walker for 4-space / tab indented code
*
* An indented line directly under a list item (or under another
* continuation line of that item) is wrapped item text, not code, so it
* is passed through untouched for processLists to fold. A blank line
* ends the item, so `- item\n\n code` still produces a code block.
*/
function processIndentedCodeBlocks(text, getAttr, dataQd) {
const lines = text.split('\n');
const result = [];
let i = 0;
let inListItem = false;
while (i < lines.length) {
const line = lines[i];
if (LIST_ITEM_RE.test(line)) {
inListItem = true;
result.push(line);
i++;
continue;
}
if (inListItem) {
if (isListContinuation(line)) {
result.push(line);
i++;
continue;
}
inListItem = false;
}
if (!isIndentedCodeLine(line)) {
result.push(line);
i++;
continue;
}
const codeLines = [];
while (i < lines.length) {
const line = lines[i];
if (line === '') {
if (i + 1 < lines.length && isIndentedCodeLine(lines[i + 1])) {
codeLines.push('');
i++;
continue;
}
break;
}
if (isIndentedCodeLine(line)) {
codeLines.push(stripCodeIndent(line));
i++;
} else {
break;
}
}
// Join with a placeholder so the paragraph / line-break pass cannot
// turn blank lines into </p><p> or newlines into <br> inside <pre>.
const codeBody = codeLines.join(PLACEHOLDER_NL);
result.push(`<pre${getAttr('pre')}${dataQd(' ')}><code${getAttr('code')}>${codeBody}</code></pre>`);
}
return result.join('\n');
}
/** Split a table row into cell strings. */
function parseTableCells(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
}
// ════════════════════════════════════════════════════════════════════
// Main parser function
// ════════════════════════════════════════════════════════════════════
function quikdown(markdown, options = {}) {
// ── Guard: only process non-empty strings ──
if (!markdown || typeof markdown !== 'string') {
return '';
}
// ── Unpack options ──
const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false, heading_ids = false, reference_links = false, footnotes = false } = options;
const styles = QUIKDOWN_STYLES;
const getAttr = createGetAttr(inline_styles, styles);
const headingSlugCounts = new Map();
// ── Helpers (closed over options) ──
/** Escape the five HTML-special characters. */
function escapeHtml(text) {
return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
}
/**
* Bidirectional marker helper.
* When bidirectional mode is on, returns ` data-qd="…"`.
* The non-bidirectional branch is a trivial no-op arrow; it is
* exercised in the core bundle but never in quikdown_bd.
*/
/* istanbul ignore next - trivial no-op fallback */
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
/**
* Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
* Returns '#' for blocked URLs.
*/
function sanitizeUrl(url, allowUnsafe = false) {
/* istanbul ignore next - defensive programming, regex ensures url is never empty */
if (!url) return '';
if (allowUnsafe) return url;
const trimmedUrl = url.trim();
const lowerUrl = trimmedUrl.toLowerCase();
const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
for (const protocol of dangerousProtocols) {
if (lowerUrl.startsWith(protocol)) {
if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
return trimmedUrl;
}
return '#';
}
}
return trimmedUrl;
}
/**
* Sanitize attributes on an HTML tag string for limited mode.
* Strips on* event handlers (case-insensitive) and runs sanitizeUrl()
* on href/src/action/formaction values.
*/
function sanitizeHtmlTagAttrs(tagStr) {
// Self-closing or void tag without attributes — pass through
if (!/\s/.test(tagStr.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*/, '').replace(/\/?>$/, ''))) {
return tagStr;
}
// Parse: <tagname ...attrs... > or <tagname ...attrs... />
const m = tagStr.match(/^(<\/?[a-zA-Z][a-zA-Z0-9]*)([\s\S]*?)(\/?>)$/);
/* istanbul ignore next - defensive: Phase 1.5 regex guarantees valid tag shape */
if (!m) return tagStr;
const [, open, attrStr, close] = m;
// Match individual attributes: name="value", name='value', name=value, or bare name
// eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers
const attrRe = /([a-zA-Z_][\w\-.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
const attrs = [];
let am;
while ((am = attrRe.exec(attrStr)) !== null) {
const name = am[1];
const value = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
// Strip event handlers (on*)
if (/^on/i.test(name)) continue;
if (value === undefined) {
// Boolean attribute (e.g. disabled, checked)
attrs.push(name);
} else {
let sanitized = value;
if (name.toLowerCase() in URL_ATTRIBUTES) {
sanitized = sanitizeUrl(value);
}
attrs.push(`${name}="${sanitized}"`);
}
}
return open + (attrs.length ? ' ' + attrs.join(' ') : '') + close;
}
// ────────────────────────────────────────────────────────────────
// Phase 1 — Code Extraction
// ────────────────────────────────────────────────────────────────
// Why extract code first? Fenced blocks and inline code spans can
// contain markdown-like characters (*, _, #, |, etc.) that must NOT
// be interpreted as formatting. By pulling them out and replacing
// with unique placeholders, the rest of the pipeline never sees them.
let html = markdown;
const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
const inlineCodes = []; // Array of escaped-HTML strings
// ── Fenced code blocks ──
// Matches paired fences: ``` with ``` and ~~~ with ~~~.
// The fence must start at column 0 of a line (^ with /m flag).
// Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
const langTrimmed = lang ? lang.trim() : '';
if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
// Custom plugin — store raw code (un-escaped) so the plugin
// receives the original source.
codeBlocks.push({
lang: langTrimmed,
code: code.trimEnd(),
custom: true,
fence: fence,
hasReverse: !!fence_plugin.reverse
});
} else {
// Default — pre-escape the code for safe HTML output.
codeBlocks.push({
lang: langTrimmed,
code: escapeHtml(code.trimEnd()),
custom: false,
fence: fence
});
}
return placeholder;
});
// ── Escaped backticks ──
// Extract \` before inline code extraction so an escaped backtick
// does not participate in code span pairing.
const backslashEscapes = [];
html = html.replace(/\\`/g, () => {
const placeholder = `${PLACEHOLDER_BE}${backslashEscapes.length}§`;
backslashEscapes.push('`');
return placeholder;
});
// ── Inline code spans ──
// Matches a single backtick pair: `content`.
// Content is captured and HTML-escaped immediately.
html = html.replace(/`([^`\n]+)`/g, (match, code) => {
const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
inlineCodes.push(escapeHtml(code));
return placeholder;
});
// ────────────────────────────────────────────────────────────────
// Phase 1.25 — Backslash Escape Extraction
// ────────────────────────────────────────────────────────────────
// Extract remaining backslash-escaped ASCII punctuation so those
// characters are not interpreted as markdown formatting. Runs
// after code extraction (so \* inside code blocks and inline code
// is already protected) and before HTML escaping.
html = html.replace(/\\([\\*_{}[\]()#+\-.!~|<>])/g, (match, char) => {
const placeholder = `${PLACEHOLDER_BE}${backslashEscapes.length}§`;
backslashEscapes.push(escapeHtml(char));
return placeholder;
});
// ────────────────────────────────────────────────────────────────
// Phase 1.75 — Reference Link & Footnote Definition Collection
// ────────────────────────────────────────────────────────────────
// Scan lines BEFORE HTML escaping to collect definitions.
// [id]: url "title" — reference link definition
// [^id]: text — footnote definition (with indented continuation)
// Characters [, ], :, ^ are NOT in the HTML escape map so they
// survive all phases unchanged. Definition lines are stripped.
const refDefs = new Map(); // id (lowercase) → { url, title }
const fnDefs = new Map(); // id → text
const fnOrder = []; // ordered list of footnote ids as referenced
if (reference_links || footnotes) {
const lines = html.split('\n');
const kept = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Skip lines inside code block placeholders
if (line.includes(PLACEHOLDER_CB)) {
kept.push(line);
i++;
continue;
}
// Footnote definition: [^id]: text
if (footnotes) {
const fnMatch = line.match(/^\[\^([^\]]+)\]:\s+([\s\S]*)$/);
if (fnMatch) {
const fnId = fnMatch[1];
let fnText = fnMatch[2];
// Collect indented continuation lines
while (i + 1 < lines.length) {
const next = lines[i + 1];
if (/^[ \t]+\S/.test(next) && !next.includes(PLACEHOLDER_CB)) {
fnText += ' ' + next.trim();
i++;
} else {
break;
}
}
// First definition wins
const key = fnId;
if (!fnDefs.has(key)) {
fnDefs.set(key, fnText);
}
i++;
continue;
}
}
// Reference link definition: [id]: url "title"
if (reference_links) {
// eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers on same path
const refMatch = line.match(/^\[([^\]]+)\]:\s+<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?\s*$/);
if (refMatch) {
const refId = refMatch[1].toLowerCase();
const url = refMatch[2];
const title = refMatch[3] !== undefined ? refMatch[3]
: refMatch[4] !== undefined ? refMatch[4]
: refMatch[5] !== undefined ? refMatch[5]
: null;
// First definition wins
if (!refDefs.has(refId)) {
refDefs.set(refId, { url, title });
}
i++;
continue;
}
}
kept.push(line);
i++;
}
html = kept.join('\n');
}
// ────────────────────────────────────────────────────────────────
// Phase 1.5 — Safe HTML Extraction (whitelist mode)
// ────────────────────────────────────────────────────────────────
// When allow_unsafe_html is an object or array, extract whitelisted
// HTML tags, sanitize their attributes, and replace with placeholders.
// Non-whitelisted tags stay in text so Phase 2 will escape them.
const safeTags = [];
// Normalize: array → object for O(1) lookup; object used as-is
const htmlAllow = Array.isArray(allow_unsafe_html)
? Object.fromEntries(allow_unsafe_html.map(t => [t, 1]))
: (allow_unsafe_html && typeof allow_unsafe_html === 'object') ? allow_unsafe_html : null;
if (htmlAllow) {
// Pass through HTML comments — browsers render them as nothing
html = html.replace(/<!--[\s\S]*?-->/g, (match) => {
const idx = safeTags.length;
safeTags.push(match);
return `${PLACEHOLDER_HT}${idx}§`;
});
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
if (tagName.toLowerCase() in htmlAllow) {
const sanitized = sanitizeHtmlTagAttrs(match);
const idx = safeTags.length;
safeTags.push(sanitized);
return `${PLACEHOLDER_HT}${idx}§`;
}
// Not whitelisted — leave in text for Phase 2 to escape
return match;
});
}
// ────────────────────────────────────────────────────────────────
// Phase 2 — HTML Escaping
// ────────────────────────────────────────────────────────────────
// All remaining text (everything except code placeholders) is escaped
// to prevent XSS. The `allow_unsafe_html` option skips this for
// trusted pipelines that intentionally embed raw HTML.
// For whitelist mode, escaping still runs (only `true` bypasses it).
if (allow_unsafe_html !== true) {
html = escapeHtml(html);
}
// Restore safe HTML tag placeholders after escaping
if (htmlAllow) {
safeTags.forEach((tag, i) => {
html = html.replace(`${PLACEHOLDER_HT}${i}§`, tag);
});
}
// ────────────────────────────────────────────────────────────────
// Phase 3 — Block Scanning + Inline Formatting + Paragraphs
// ────────────────────────────────────────────────────────────────
// This is the heart of the lexer rewrite. Instead of applying
// 10+ global regex passes, we:
// 0. Process indented code blocks (4-space / tab, before other blocks)
// 1. Process tables (line walker — tables need multi-line lookahead)
// 2. Scan remaining lines for headings, HR, blockquotes
// 3. Process lists (line walker — lists need indent tracking)
// 4. Apply inline formatting to all text content
// 5. Wrap remaining text in <p> tags
//
// Steps 0, 1 and 3 are line-walkers that process the full text in a
// single pass each. Step 2 replaces global regex with a per-line
// scanner. Steps 4-5 are applied to the result.
//
// Total: 4 structured passes instead of 10+ regex passes.
// ── Step 0: Indented code blocks ──
html = processIndentedCodeBlocks(html, getAttr, dataQd);
// ── Step 1: Tables ──
// Tables need multi-line lookahead (header → separator → body rows)
// so they're handled by a dedicated line-walker first.
html = processTable(html, getAttr, bidirectional);
// ── Step 2: Headings, HR, Blockquotes ──
// These are simple line-level constructs. We scan each line once
// and replace matching lines with their HTML representation.
html = scanLineBlocks(html, getAttr, dataQd, heading_ids, headingSlugCounts, escapeHtml);
// ── Step 3: Lists ──
// Lists need indent-level tracking across lines, so they get their
// own line-walker.
html = processLists(html, getAttr, inline_styles, bidirectional);
// ── Step 4: Inline formatting ──
// Apply bold, italic, strikethrough, images, links, and autolinks
// to all text content. This runs on the output of steps 1-3, so
// it sees text inside headings, blockquotes, table cells, list
// items, and paragraph text.
// Images (must come before links —  vs [text](url))
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, dest) => {
const { url, title } = parseLinkDestination(dest);
const sanitizedSrc = sanitizeUrl(url, options.allow_unsafe_urls);
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
/* istanbul ignore next - bd-only branch */
const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
/* istanbul ignore next - bd-only branch */
const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(url)}"` : '';
/* istanbul ignore next - bd-only branch */
const titleQd = bidirectional && title ? ` data-qd-title="${escapeHtml(title)}"` : '';
return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${titleAttr}${altAttr}${srcAttr}${titleQd}${dataQd('!')}>`;
});
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, dest) => {
const { url, title } = parseLinkDestination(dest);
const sanitizedHref = sanitizeUrl(url, options.allow_unsafe_urls);
const isExternal = /^https?:\/\//i.test(sanitizedHref);
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
/* istanbul ignore next - bd-only branch */
const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
/* istanbul ignore next - bd-only branch */
const titleQd = bidirectional && title ? ` data-qd-title="${escapeHtml(title)}"` : '';
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${titleAttr}${textAttr}${titleQd}${dataQd('[')}>${text}</a>`;
});
// Autolinks — bare https?:// URLs become clickable <a> tags
html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, rawUrl) => {
const { url, trailing } = stripTrailingPunctuation(rawUrl);
const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>${trailing}`;
});
// ── Phase 3.5: Reference Link & Footnote Resolution ──
// Resolve [text][id], [text][], [id] patterns to <a> tags using
// collected definitions. Footnote markers [^id] become <sup> links.
if (reference_links && refDefs.size > 0) {
/** Build an <a> tag from a resolved reference definition. */
function buildRefAnchor(def, id, displayText) {
const sanitizedHref = sanitizeUrl(def.url, options.allow_unsafe_urls);
const isExternal = /^https?:\/\//i.test(sanitizedHref);
const rel = isExternal ? ' rel="noopener noreferrer"' : '';
const titleAttr = def.title ? ` title="${escapeHtml(def.title)}"` : '';
/* istanbul ignore next - bd-only branch */
const refAttr = bidirectional ? ` data-qd-ref="${escapeHtml(id)}"` : '';
return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${titleAttr}${refAttr}${dataQd('[ref')}>${displayText}</a>`;
}
// Full reference: [text][id] and collapsed: [text][]
html = html.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (match, text, id) => {
const def = refDefs.get((id === '' ? text : id).toLowerCase());
return def ? buildRefAnchor(def, id, text) : match;
});
// Shortcut reference: [id] (not followed by ( or [, not containing ^)
html = html.replace(/(?<!\])\[([^\]^[\n]+)\](?!\(|\[)/g, (match, id) => {
const def = refDefs.get(id.toLowerCase());
return def ? buildRefAnchor(def, id, id) : match;
});
}
if (footnotes && fnDefs.size > 0) {
// Footnote markers: [^id]
html = html.replace(/\[\^([^\]]+)\]/g, (match, id) => {
if (!fnDefs.has(id)) return match; // unresolved — leave as text
// Track ordering — each unique id gets a sequential number
let fnNum = fnOrder.indexOf(id);
if (fnNum === -1) {
fnOrder.push(id);
fnNum = fnOrder.length;
} else {
fnNum = fnNum + 1;
}
/* istanbul ignore next - bd-only branch */
const fnAttr = bidirectional ? ` data-qd-fn="${escapeHtml(id)}"` : '';
return `<sup${getAttr('sup')}${fnAttr}${dataQd('[^')}><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">${fnNum}</a></sup>`;
});
}
// Protect rendered tags so emphasis regexes don't see attribute
// values — fixes #3 (underscores in URLs interpreted as emphasis).
const savedTags = [];
html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
// Bold, italic, strikethrough (reuses BASE_INLINE_PATTERNS; code spans
// are already extracted in Phase 1 so only the first 5 patterns apply here)
const emphasisMarkers = ['**', '__', '*', '_', '~~'];
for (let pi = 0; pi < 5; pi++) {
const [pattern, tag] = BASE_INLINE_PATTERNS[pi];
html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(emphasisMarkers[pi])}>$1</${tag}>`);
}
// Restore protected tags
html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
// ── Step 5: Line breaks + paragraph wrapping ──
// Backslash at end of line → hard line break (CommonMark)
html = html.replace(/\\\n/g, `<br${getAttr('br')}>`);
if (lazy_linefeeds) {
// Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
// • Double newlines → paragraph break
// • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
//
// Strategy: protect block-adjacent newlines with §N§, convert
// the rest, then restore.
const blocks = [];
let bi = 0;
// Protect tables and lists from <br> injection
html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
blocks[bi] = m;
return `§B${bi++}§`;
});
html = html.replace(/\n\n+/g, '§P§')
// After block-level closing tags
.replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
.replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
// Before block-level opening tags
.replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
.replace(/\n(§B\d+§)/g, '§N§$1')
.replace(/(§B\d+§)\n/g, '$1§N§')
// Convert surviving newlines to <br>
.replace(/\n/g, `<br${getAttr('br')}>`)
// Restore
.replace(/§N§/g, '\n')
.replace(/§P§/g, '</p><p>');
// Restore protected blocks
blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
html = '<p>' + html + '</p>';
} else {
// Standard mode: two trailing spaces → <br>, double newline → new paragraph
html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
html = html.replace(/\n\n+/g, (match, offset) => {
const before = html.substring(0, offset);
if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
return '<p>';
}
return '</p><p>';
});
html = '<p>' + html + '</p>';
}
// ── Step 6: Cleanup ──
// Remove <p> wrappers that accidentally enclose block elements.
// This is simpler than trying to prevent them during wrapping.
const cleanupPatterns = [
[/<p><\/p>/g, ''],
[/<p>(<h[1-6][^>]*>)/g, '$1'],
[/(<\/h[1-6]>)<\/p>/g, '$1'],
[/<p>(<blockquote[^>]*>)/g, '$1'],
[/(<\/blockquote>)<\/p>/g, '$1'],
[/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
[/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
[/<p>(<hr[^>]*>)<\/p>/g, '$1'],
[/<p>(<table[^>]*>)/g, '$1'],
[/(<\/table>)<\/p>/g, '$1'],
[/<p>(<pre[^>]*>)/g, '$1'],
[/(<\/pre>)<\/p>/g, '$1'],
[/<p>(<div[^>]*>)/g, '$1'],
[/(<\/div>)<\/p>/g, '$1'],
[/<p>(<section[^>]*>)/g, '$1'],
[/(<\/section>)<\/p>/g, '$1'],
[new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
];
cleanupPatterns.forEach(([pattern, replacement]) => {
html = html.replace(pattern, replacement);
});
// When a block element is followed by a newline and then text, open a <p>.
html = html.replace(/(<\/(?:h[1-6]|blockquote|div|section|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
// ── Footnotes section ──
// Only rendered if footnotes were actually referenced in the document.
if (footnotes && fnOrder.length > 0) {
/* istanbul ignore next - bd-only branch */
const sectionAttr = bidirectional ? ` data-qd="[^section"` : '';
let fnSection = `<section${getAttr('footnotes')}${sectionAttr}>`;
fnSection += `<hr${getAttr('hr')}>`;
fnSection += `<ol${getAttr('ol')}>`;
for (const id of fnOrder) {
const rawText = fnDefs.get(id) || '';
// Escape HTML in footnote text, then apply inline formatting
let fnHtml = allow_unsafe_html === true ? rawText : escapeHtml(rawText);
for (const [pattern, tag] of BASE_INLINE_PATTERNS) {
fnHtml = fnHtml.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
}
/* istanbul ignore next - bd-only branch */
const liAttr = bidirectional ? ` data-qd-fn-id="${escapeHtml(id)}"` : '';
fnSection += `<li${getAttr('li')} id="fn-${escapeHtml(id)}"${liAttr}>${fnHtml} <a href="#fnref-${escapeHtml(id)}"${getAttr('footnote-backref')}>↩</a></li>`;
}
fnSection += '</ol></section>';
html += fnSection;
}
// ────────────────────────────────────────────────────────────────
// Phase 4 — Code Restoration
// ────────────────────────────────────────────────────────────────
// Replace placeholders with rendered HTML. For fenced blocks this
// means wrapping in <pre><code>…</code></pre> (or calling the
// fence_plugin). For inline code it means <code>…</code>.
codeBlocks.forEach((block, i) => {
let replacement;
if (block.custom && fence_plugin && fence_plugin.render) {
// Delegate to the user-provided fence plugin.
replacement = fence_plugin.render(block.code, block.lang);
if (replacement === undefined) {
// Plugin declined — fall back to default rendering.
const langClass = !inline_styles && block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
const codeAttr = inline_styles ? getAttr('code') : langClass;
/* istanbul ignore next - bd-only branch */
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
/* istanbul ignore next - bd-only branch */
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
} else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
// Plugin returned HTML — inject data attributes for roundtrip.
replacement = replacement.replace(/^<(\w+)/,
`<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
}
} else {
// Default rendering — wrap in <pre><code>.
const langClass = !inline_styles && block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
const codeAttr = inline_styles ? getAttr('code') : langClass;
/* istanbul ignore next - bd-only branch */
const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
/* istanbul ignore next - bd-only branch */
const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
}
const placeholder = `${PLACEHOLDER_CB}${i}§`;
html = html.replace(placeholder, replacement);
});
// Restore inline code spans
inlineCodes.forEach((code, i) => {
const placeholder = `${PLACEHOLDER_IC}${i}§`;
html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
});
// Restore backslash escapes
backslashEscapes.forEach((char, i) => {
html = html.replace(`${PLACEHOLDER_BE}${i}§`, char);
});
// Restore newlines inside indented code blocks
html = html.split(PLACEHOLDER_NL).join('\n');
return html.trim();
}
// ════════════════════════════════════════════════════════════════════
// Block-level line scanner
// ════════════════════════════════════════════════════════════════════
/**
* scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
*
* Walks the text line by line. For each line it checks (in order):
* 1. Heading — starts with 1-6 '#' followed by a space
* 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
* 3. Blockquote — starts with '> ' (the > was already HTML-escaped)
*
* Lines that don't match any block pattern are passed through unchanged.
*
* This replaces three separate global regex passes from the pre-1.2.8
* architecture with one structured scan.
*
* @param {string} text The document text (HTML-escaped, code extracted)
* @param {Function} getAttr Attribute factory (class or style)
* @param {Function} dataQd Bidirectional marker factory
* @param {boolean} headingIds Emit id attributes on headings
* @param {Map} slugCounts Duplicate slug tracker
* @param {Function} escapeHtmlFn Escape helper for id attributes
* @returns {string} Text with block-level elements rendered
*/
function scanLineBlocks(text, getAttr, dataQd, headingIds, slugCounts, escapeHtmlFn) {
const lines = text.split('\n');
const result = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// ── Markdown comment (reference-link hack) ──
// [//]: # (comment) or [//]: # "comment" or [//]: #
// These produce no output — standard markdown comment convention.
if (/^\[\/\/\]: #/.test(line)) {
i++;
continue;
}
// ── Heading ──
// Count leading '#' characters. Valid heading: 1-6 hashes then a space.
// Example: "## Hello World ##" → <h2>Hello World</h2>
let hashCount = 0;
while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
hashCount++;
}
if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
// Extract content after "# " and strip trailing hashes
const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
const tag = 'h' + hashCount;
let idAttr = '';
if (headingIds) {
const slug = uniqueSlug(headingSlug(content), slugCounts);
idAttr = ` id="${escapeHtmlFn(slug)}"`;
}
result.push(`<${tag}${getAttr(tag)}${idAttr}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
i++;
continue;
}
// ── Horizontal Rule ──
// Three or more identical chars (-, *, _), optional interspersed spaces.
if (isHRLine(line)) {
result.push(`<hr${getAttr('hr')}${dataQd(line.trim())}>`);
i++;
continue;
}
// ── Blockquote ──
// After Phase 2, the '>' character has been escaped to '>'.
// Supports nested levels: >> inner, > > inner
// Supports lazy continuation: lines without > prefix continue
// the blockquote at the previous depth.
if (parseBlockquoteLine(line).depth > 0) {
const bqLines = [];
let lastDepth = 0;
while (i < lines.length) {
const parsed = parseBlockquoteLine(lines[i]);
if (parsed.depth > 0) {
bqLines.push(parsed);
lastDepth = parsed.depth;
i++;
} else if (lastDepth > 0 && !isLazyContinuationBreaker(lines[i])) {
bqLines.push({ depth: lastDepth, content: lines[i] });
i++;
} else {
break;
}
}
result.push(renderNestedBlockquotes(bqLines, getAttr, dataQd));
continue;
}
// ── Pass-through ──
result.push(line);
i++;
}
return result.join('\n');
}
// ════════════════════════════════════════════════════════════════════
// Table processing (line walker)
// ════════════════════════════════════════════════════════════════════
/**
* Inline markdown formatter for table cells.
* Handles bold, italic, strikethrough, and code within cell text.
* Links / images / autolinks are handled by the global inline pass
* (Phase 3 Step 4) which runs after table processing.
*/
function processInlineMarkdown(text, getAttr) {
const patterns = [
[/\*\*(.+?)\*\*/g, 'strong'],
[/__(.+?)__/g, 'strong'],
[/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
[/(?<![A-Za-z0-9_])_(?![_\s])(.+?)(?<![\s_])_(?![A-Za-z0-9_])/g, 'em'],
[/~~(.+?)~~/g, 'del'],
[/`([^`\n]+)`/g, 'code']
];
patterns.forEach(([pattern, tag]) => {
text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
});
return text;
}
/**
* processTable — line walker for markdown tables
*
* Walks through lines looking for runs of pipe-containing lines.
* Each run is validated (must contain a separator row: |---|---|)
* and rendered as an HTML <table>. Invalid runs are restored as-is.
*
* @param {string} text Full document text
* @param {Function} getAttr Attribute factory
* @returns {string} Text with tables rendered
*/
function processTable(text, getAttr, bidirectional) {
const lines = text.split('\n');
const result = [];
let inTable = false;
let tableLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
if (!inTable) {
inTable = true;
tableLines = [];
}
tableLines.push(line);
} else {
if (inTable) {
const tableHtml = buildTable(tableLines, getAttr, bidirectional);
if (tableHtml) {
result.push(tableHtml);
} else {
result.push(...tableLines);
}
inTable = false;
tableLines = [];
}
result.push(lines[i]);
}
}
// Handle table at end of document
if (inTable && tableLines.length > 0) {
const tableHtml = buildTable(tableLines, getAttr, bidirectional);
if (tableHtml) {
result.push(tableHtml);
} else {
result.push(...tableLines);
}
}
return result.join('\n');
}
/**
* buildTable — validate and render a table from accumulated lines
*
* @param {string[]} lines Array of pipe-containing lines
* @param {Function} getAttr Attribute factory
* @returns {string|null} HTML table string, or null if invalid
*/
function buildTable(lines, getAttr, bidirectional) {
if (lines.length < 2) return null;
// Find the separator row (---|---|)
let separatorIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
separatorIndex = i;
break;
}
}
let headerLines;
let bodyLines;
let alignments;
if (separatorIndex === -1) {
// No separator row (common in LLM output) — first row is header, rest is body
headerLines = [lines[0]];
bodyLines = lines.slice(1);
alignments = parseTableCells(lines[0]).map(() => 'left');
} else {
headerLines = lines.slice(0, separatorIndex);
bodyLines = lines.slice(separatorIndex + 1);
const separator = lines[separatorIndex];
const separatorCells = parseTableCells(separator);
alignments = separatorCells.map(cell => {
const trimmed = cell.trim();
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
if (trimmed.endsWith(':')) return 'right';
return 'left';
});
}
const colCount = alignments.length;
/* istanbul ignore next - bd-only branch */
const alignAttr = bidirectional ? ` data-qd-align="${alignments.join(',')}"` : '';
let html = `<table${getAttr('table')}${alignAttr}>\n`;
// Header
html += `<thead${getAttr('thead')}>\n`;
headerLines.forEach(line => {
html += `<tr${getAttr('tr')}>\n`;
const cells = parseTableCells(line);
for (let i = 0; i < colCount; i++) {
const cell = i < cells.length ? cells[i] : '';
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
}
html += '</tr>\n';
});
html += '</thead>\n';
// Body
if (bodyLines.length > 0) {
html += `<tbody${getAttr('tbody')}>\n`;
bodyLines.forEach(line => {
html += `<tr${getAttr('tr')}>\n`;
const cells = parseTableCells(line);
for (let i = 0; i < colCount; i++) {
const cell = i < cells.length ? cells[i] : '';
const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
const processedCell = processInlineMarkdown(cell.trim(), getAttr);
html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
}
html += '</tr>\n';
});
html += '</tbody>\n';
}
html += '</table>';
return html;
}
// ════════════════════════════════════════════════════════════════════
// List processing (line walker)
// ════════════════════════════════════════════════════════════════════
/**
* processLists — line walker for ordered, unordered, and task lists
*
* Scans each line for list markers (-, *, +, 1., 2., etc.) with
* optional leading indentation for nesting. The indent of the item
* that opens the outermost list is the base, so a list that starts at
* indent 2 is a top-level list. An indented, non-blank line directly
* under an item is wrapped item text and is folded into that item
* (two trailing spaces or a trailing backslash on the item keep a hard
* break). Any other non-list line closes all open lists and passes
* through unchanged.
*
* Task lists (- [ ] / - [x]) are detected and rendered with
* checkbox inputs.
*
* @param {string} text Full document text
* @param {Function} getAttr Attribute factory
* @param {boolean} inline_styles Whether to use inline styles
* @param {boolean} bidirectional Whether to add data-qd markers
* @returns {string} Text with lists rendered
*/
function processLists(text, getAttr, inline_styles, bidirectional) {
const lines = text.split('\n');
const result = [];
const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
let baseIndent = 0; // indent of the item that opened the outermost list
let lastWasItem = false; // result[last] is an <li> a continuation line may extend
// Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
// `+`, `1.`, etc.) never contain HTML-special chars, so the replace
// callback is defensive-only and never actually fires in practice.
/* istanbul ignore next - defensive: list markers never trigger escaping */
const escapeHtml = (text) => text.replace(/[&<>"']/g,
/* istanbul ignore next - defensive: list markers never contain HTML specials */
m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
/* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(LIST_ITEM_RE);
if (match) {
const [, indent, marker, content] = match;
if (listStack.length === 0) baseIndent = indent.length;
const level = Math.max(0, Math.floor((indent.length - baseIndent) / 2));
const isOrdered = /^\d+\./.test(marker);
const listType = isOrdered ? 'ol' : 'ul';
// Task list detection (only in unordered lists)
let listItemContent = content;
let taskListClass = '';
const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
if (taskMatch && !isOrdered) {
const [, checked, taskContent] = taskMatch;
const isChecked = checked.toLowerCase() === 'x';
const checkboxAttr = inline_styles
? ' style="margin-right:.5em"'
: ` class="${CLASS_PREFIX}task-checkbox"`;
listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
}
// Close deeper nesting levels
while (listStack.length > level + 1) {
const list = listStack.pop();
result.push(`</${list.type}>`);
}
// Switch list type at the current level
if (listStack.length === level + 1 && listStack[level].type !== listType) {
result.push(`</${listStack.pop().type}>`);
}
// Open lists up to the current level. More than one opens when
// the indent jumps (`- a\n - deep`) so no <li> is left bare.
while (listStack.length < level + 1) {
listStack.push({ type: listType, level: listStack.length });
result.push(`<${listType}${getAttr(listType)}>`);
}
const liAttr = taskListClass || getAttr('li');
result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
lastWasItem = true;
} else if (lastWasItem && isListContinuation(line)) {
// Wrapped item text — fold into the <li> above (issue #36)
let body = result[result.length - 1].slice(0, -'</li>'.length);
if (/( {2}|\\)$/.test(body)) {
body = body.replace(/( {2}|\\)$/, `<br${getAttr('br')}>`);
} else {
body += ' ';
}
result[result.length - 1] = `${body}${line.trim()}</li>`;
} else {
// Not a list item — close all open lists
while (listStack.length > 0) {
const list = listStack.pop();
result.push(`</${list.type}>`);
}
result.push(line);
lastWasItem = false;
}
}
// Close any remaining open lists
while (listStack.length > 0) {
const list = listStack.pop();
result.push(`</${list.type}>`);
}
return result.join('\n');
}
// ════════════════════════════════════════════════════════════════════
// Static API
// ════════════════════════════════════════════════════════════════════
/**
* Emit CSS rules for all quikdown elements.
*
* @param {string} prefix Class prefix (default: 'quikdown-')
* @param {string} theme 'light' (default) or 'dark'
* @returns {string} CSS text
*/
quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
const styles = QUIKDOWN_STYLES;
const themeOverrides = {
dark: {
'#f4f4f4': '#2a2a2a', // pre background
'#f0f0f0': '#2a2a2a', // code background
'#f2f2f2': '#2a2a2a', // th background
'#ddd': '#3a3a3a', // borders
'#06c': '#6db3f2', // links
'#ddf4ff': '#162d50', // alert-note bg
'#dafbe1': '#16351d', // alert-tip bg
'#fbefff': '#2d1a42', // alert-important bg
'#fff8c5': '#342a10', // alert-warning bg
'#ffebe9': '#3d1418', // alert-caution bg
_textColor: '#e0e0e0'
},
light: {
_textColor: '#333'
}
};
let css = '';
for (const [tag, style] of Object.entries(styles)) {
let themedStyle = style;
if (theme === 'dark' && themeOverrides.dark) {
for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
if (!oldColor.startsWith('_')) {
themedStyle = themedStyle.replaceAll(oldColor, newColor);
}
}
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote', 'alert', 'alert-title'];
if (needsTextColor.includes(tag)) {
themedStyle += `;color:${themeOverrides.dark._textColor}`;
}
} else /* istanbul ignore next */ if (theme === 'light' && themeOverrides.light) {
const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote', 'alert', 'alert-title'];
if (needsTextColor.includes(tag)) {
themedStyle += `;color:${themeOverrides.light._textColor}`;
}
}
css += `.${prefix}${tag} { ${themedStyle} }\n`;
}
return css;
};
/**
* Create a pre-configured parser with baked-in options.
*
* @param {Object} options Options to bake in
* @returns {Function} Configured quikdown(markdown) function
*/
quikdown.configure = function(options) {
return function(markdown) {
return quikdown(markdown, options);
};
};
/** Semantic version (injected at build time) */
quikdown.version = quikdownVersion;
// ════════════════════════════════════════════════════════════════════
// Exports
// ════════════════════════════════════════════════════════════════════
/* istanbul ignore next */
if (typeof module !== 'undefined' && module.exports) {
module.exports = quikdown;
}
/* istanbul ignore next */
if (typeof window !== 'undefined') {
window.quikdown = quikdown;
}
/**
* quikdown_bd - Bidirectional markdown/HTML converter
* Extends core quikdown with HTML→Markdown conversion
*
* Uses data-qd attributes to preserve original markdown syntax
* Enables HTML→Markdown conversion for quikdown-generated HTML
*/
/**
* Create bidirectional version by extending quikdown
* This wraps quikdown and adds the toMarkdown method
*/
function quikdown_bd(markdown, options = {}) {
// Use core quikdown with bidirectional flag to add data-qd attributes
return quikdown(markdown, { ...options, bidirectional: true });
}
// Copy all properties and methods from quikdown (including version).
// Skip `configure` — quikdown_bd provides its own override below, so the
// inner quikdown.configure is dead code in this bundle.
Object.keys(quikdown).forEach(key => {
if (key === 'configure') return;
quikdown_bd[key] = quikdown[key];
});
// Add the toMarkdown method for HTML→Markdown conversion
quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
// Accept either HTML string or DOM element
let container;
if (typeof htmlOrElement === 'string') {
container = document.createElement('div');
container.innerHTML = htmlOrElement;
} else if (htmlOrElement instanceof Element) {
/* istanbul ignore next - browser-only code path, not testable in jsdom */
container = htmlOrElement;
} else {
return '';
}
// Walk the DOM tree and reconstruct markdown
function walkNode(node, parentContext = {}) {
if (node.nodeType === Node.TEXT_NODE) {
// Return text content, preserving whitespace where needed
return node.textContent;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const tag = node.tagName.toLowerCase();
const dataQd = node.getAttribute('data-qd');
// Process children with context
let childContent = '';
for (const child of node.childNodes) {
childContent += walkNode(child, { parentTag: tag, ...parentContext });
}
// Determine markdown based on element and attributes
switch (tag) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
const level = parseInt(tag[1]);
const prefix = dataQd || '#'.repeat(level);
return `${prefix} ${childContent.trim()}\n\n`;
case 'strong':
case 'b':
if (!childContent) return ''; // Don't add markers for empty content
const boldMarker = dataQd || '**';
return `${boldMarker}${childContent}${boldMarker}`;
case 'em':
case 'i':
if (!childContent) return ''; // Don't add markers for empty content
const emMarker = dataQd || '*';
return `${emMarker}${childContent}${emMarker}`;
case 'del':
case 's':
case 'strike':
if (!childContent) return ''; // Don't add markers for empty content
const delMarker = dataQd || '~~';
return `${delMarker}${childContent}${delMarker}`;
case 'code':
// Note: code inside pre is handled directly by the pre case using querySelector
if (!childContent) return ''; // Don't add markers for empty content
const codeMarker = dataQd || '`';
return `${codeMarker}${childContent}${codeMarker}`;
case 'pre':
const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
const lang = node.getAttribute('data-qd-lang') || '';
// Check if this was created by a fence plugin with reverse handler
if (options.fence_plugin && options.fence_plugin.reverse && lang) {
try {
const result = options.fence_plugin.reverse(node);
if (result && result.content) {
const fenceMarker = result.fence || fence;
const langStr = result.lang || lang;
return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
}
} catch (err) {
console.warn('Fence reverse handler error:', err);
// Fall through to default handling
}
}
// Fallback: use data-qd-source if available
const source = node.getAttribute('data-qd-source');
if (source) {
return `${fence}${lang}\n${source}\n${fence}\n\n`;
}
// Final fallback: extract text content
const codeEl = node.querySelector('code');
const codeContent = codeEl ? codeEl.textContent : childContent;
return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
case 'blockquote':
const quoteMarker = dataQd || '>';
const lines = childContent.trim().split('\n');
return lines.map(line => `${quoteMarker} ${line}`).join('\n') + '\n\n';
case 'hr':
const hrMarker = dataQd || '---';
return `${hrMarker}\n\n`;
case 'br':
const brMarker = dataQd || ' ';
return `${brMarker}\n`;
case 'a': {
const href = node.getAttribute('href') || '';
// Skip footnote back-links (↩)
if (href.startsWith('#fnref-')) {
return '';
}
// Reference link
const refId = node.getAttribute('data-qd-ref');
if (refId !== null) {
const linkText = node.getAttribute('data-qd-text') || childContent.trim();
if (refId === '') {
// Collapsed reference: [text][]
return `[${linkText}][]`;
}
if (refId.toLowerCase() === linkText.toLowerCase()) {
// Shortcut reference: [id]
return `[${refId}]`;
}
// Full reference: [text][id]
return `[${linkText}][${refId}]`;
}
const linkText = node.getAttribute('data-qd-text') || childContent.trim();
// Check for autolinks
if (linkText === href && !dataQd) {
return `<${href}>`;
}
return `[${linkText}](${href})`;
}
case 'sup': {
// Footnote marker
const fnId = node.getAttribute('data-qd-fn');
if (fnId) {
return `[^${fnId}]`;
}
return childContent;
}
case 'section': {
// Footnotes section
if (dataQd === '[^section') {
// Walk <ol> children to extract footnote definitions
let fnDefs = '';
const ol = node.querySelector('ol');
if (ol) {
for (const li of ol.children) {
if (li.tagName !== 'LI') continue;
const fnId = li.getAttribute('data-qd-fn-id');
if (!fnId) continue;
// Extract text content, skipping the back-reference <a>
let text = '';
for (const child of li.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
text += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === 'A' && (child.getAttribute('href') || '').startsWith('#fnref-')) {
continue; // skip backref
}
text += child.textContent;
}
}
fnDefs += `[^${fnId}]: ${text.trim()}\n`;
}
}
return fnDefs ? '\n' + fnDefs : '';
}
return childContent;
}
case 'img':
const alt = node.getAttribute('data-qd-alt') || node.getAttribute('alt') || '';
const src = node.getAttribute('data-qd-src') || node.getAttribute('src') || '';
const imgMarker = dataQd || '!';
return `${imgMarker}[${alt}](${src})`;
case 'ul':
case 'ol':
return walkList(node, tag === 'ol') + '\n';
case 'li':
// Handled by list processor
return childContent;
case 'table':
return walkTable(node) + '\n\n';
case 'p':
// Check if it's actually a paragraph or just a wrapper
if (childContent.trim()) {
// Check if paragraph ends with a line that's just whitespace
// This indicates an intentional blank line before the next element
const lines = childContent.split('\n');
let content = childContent.trim();
// If the last line(s) are just whitespace, preserve one blank line
if (lines.length > 1) {
let trailingBlankLines = 0;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim() === '') {
trailingBlankLines++;
} else {
break;
}
}
if (trailingBlankLines > 0) {
// Add a line with just a space, followed by single newline
// The \n\n will be added below for paragraph separation
content = content + '\n ';
// Only add one newline since we're preserving the space line
return content + '\n';
}
}
return content + '\n\n';
}
return '';
case 'div':
// Check if this was created by a fence plugin with reverse handler
const divLang = node.getAttribute('data-qd-lang');
const divFence = node.getAttribute('data-qd-fence');
if (divLang && options.fence_plugin && options.fence_plugin.reverse) {
try {
const result = options.fence_plugin.reverse(node);
if (result && result.content) {
const fenceMarker = result.fence || divFence || '```';
const langStr = result.lang || divLang;
return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
}
} catch (err) {
console.warn('Fence reverse handler error:', err);
// Fall through to default handling
}
}
// Fallback: use data-qd-source if available
const divSource = node.getAttribute('data-qd-source');
if (divSource && divFence) {
return `${divFence}${divLang || ''}\n${divSource}\n${divFence}\n\n`;
}
// Check if it's a mermaid container
if (node.classList && node.classList.contains('mermaid-container')) {
const fence = node.getAttribute('data-qd-fence') || '```';
const lang = node.getAttribute('data-qd-lang') || 'mermaid';
// First check for data-qd-source attribute on the container
const source = node.getAttribute('data-qd-source');
if (source) {
// Decode HTML entities from the attribute (mainly ")
const temp = document.createElement('textarea');
temp.innerHTML = source;
const code = temp.value;
return `${fence}${lang}\n${code}\n${fence}\n\n`;
}
// Check for source on the pre.mermaid element
const mermaidPre = node.querySelector('pre.mermaid');
if (mermaidPre) {
const preSource = mermaidPre.getAttribute('data-qd-source');
if (preSource) {
const temp = document.createElement('textarea');
temp.innerHTML = preSource;
const code = temp.value;
return `${fence}${lang}\n${code}\n${fence}\n\n`;
}
}
// Fallback: Look for the legacy .mermaid-source element
const sourceElement = node.querySelector('.mermaid-source');
if (sourceElement) {
// Decode HTML entities
const temp = document.createElement('div');
temp.innerHTML = sourceElement.innerHTML;
const code = temp.textContent;
return `${fence}${lang}\n${code}\n${fence}\n\n`;
}
// Final fallback: try to extract from the mermaid element (unreliable after rendering)
const mermaidElement = node.querySelector('.mermaid');
if (mermaidElement && mermaidElement.textContent.includes('graph')) {
return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
}
}
// Check if it's a standalone mermaid diagram (legacy)
if (node.classList && node.classList.contains('mermaid')) {
const fence = node.getAttribute('data-qd-fence') || '```';
const lang = node.getAttribute('data-qd-lang') || 'mermaid';
const code = node.textContent.trim();
return `${fence}${lang}\n${code}\n${fence}\n\n`;
}
// Pass through other divs
return childContent;
case 'span':
// Pass through container elements
return childContent;
default:
return childContent;
}
}
// Walk list elements
function walkList(listNode, isOrdered, depth = 0) {
let result = '';
let index = 1;
const indent = ' '.repeat(depth);
for (const child of listNode.children) {
// The parser emits a nested list as a sibling of the <li> it
// belongs to (not inside it), so walk it one level deeper.
if (child.tagName === 'UL' || child.tagName === 'OL') {
result += walkList(child, child.tagName === 'OL', depth + 1);
continue;
}
if (child.tagName !== 'LI') continue;
const dataQd = child.getAttribute('data-qd');
let marker = dataQd || (isOrdered ? `${index}.` : '-');
// Check for task list checkbox
const checkbox = child.querySelector('input[type="checkbox"]');
if (checkbox) {
const checked = checkbox.checked ? 'x' : ' ';
marker = '-';
// Continuation lines (after <br>) indent to the item's text column
const cont = '\n' + indent + ' '.repeat(marker.length + 5);
// Get text without the checkbox
let text = '';
for (const node of child.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
} else if (node.tagName && node.tagName !== 'INPUT') {
text += walkNode(node);
}
}
result += `${indent}${marker} [${checked}] ${text.trim().replace(/\n/g, cont)}\n`;
} else {
const cont = '\n' + indent + ' '.repeat(marker.length + 1);
let text = '';
let nested = '';
for (const node of child.childNodes) {
if (node.tagName === 'UL' || node.tagName === 'OL') {
// Nested list placed inside the <li> (hand-written or edited HTML)
nested += walkList(node, node.tagName === 'OL', depth + 1);
} else {
text += walkNode(node);
}
}
result += `${indent}${marker} ${text.trim().replace(/\n/g, cont)}\n${nested}`;
}
index++;
}
return result;
}
// Walk table elements
function walkTable(table) {
let result = '';
const alignData = table.getAttribute('data-qd-align');
const alignments = alignData ? alignData.split(',') : [];
// Process header
const thead = table.querySelector('thead');
if (thead) {
const headerRow = thead.querySelector('tr');
if (headerRow) {
const headers = [];
for (const th of headerRow.querySelectorAll('th')) {
headers.push(th.textContent.trim());
}
result += '| ' + headers.join(' | ') + ' |\n';
// Add separator with alignment
const separators = headers.map((_, i) => {
const align = alignments[i] || 'left';
if (align === 'center') return ':---:';
if (align === 'right') return '---:';
return '---';
});
result += '| ' + separators.join(' | ') + ' |\n';
}
}
// Process body
const tbody = table.querySelector('tbody');
if (tbody) {
for (const row of tbody.querySelectorAll('tr')) {
const cells = [];
for (const td of row.querySelectorAll('td')) {
cells.push(td.textContent.trim());
}
if (cells.length > 0) {
result += '| ' + cells.join(' | ') + ' |\n';
}
}
}
return result.trim();
}
// Process the DOM tree
let markdown = walkNode(container);
// Clean up
markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove excessive newlines
markdown = markdown.trim();
return markdown;
};
// Override the configure method to return a bidirectional version.
// We delegate to the inner quikdown.configure so the shared closure
// machinery is exercised in both bundles (no dead code).
quikdown_bd.configure = function(options) {
const innerParser = quikdown.configure({ ...options, bidirectional: true });
return function(markdown) {
return innerParser(markdown);
};
};
// Set version
// Version is already copied from quikdown via Object.keys loop
// Export for both module and browser
/* istanbul ignore next */
if (typeof module !== 'undefined' && module.exports) {
module.exports = quikdown_bd;
}
/* istanbul ignore next */
if (typeof window !== 'undefined') {
window.quikdown_bd = quikdown_bd;
}
/**
* Rich copy functionality for QuikdownEditor
* Handles copying rendered content with proper formatting for pasting into rich text editors
*/
/**
* Get platform information
* @returns {string} The detected platform: 'macos', 'windows', 'linux', or 'unknown'
*/
function getPlatform() {
const platform = navigator.platform?.toLowerCase() || '';
const userAgent = navigator.userAgent?.toLowerCase() || '';
if (platform.includes('mac') || userAgent.includes('mac')) {
return 'macos';
} else if (userAgent.includes('windows')) {
return 'windows';
} else if (userAgent.includes('linux')) {
return 'linux';
}
return 'unknown';
}
const COPY_OUTPUT_PROFILES = ['default', 'stripped', 'quikdown'];
const COPY_HEADING_STYLES = {
h1: { fontSize: '24pt', marginTop: '0.6em', marginBottom: '0.35em' },
h2: { fontSize: '18pt', marginTop: '0.55em', marginBottom: '0.3em' },
h3: { fontSize: '15pt', marginTop: '0.5em', marginBottom: '0.25em' },
h4: { fontSize: '13pt', marginTop: '0.45em', marginBottom: '0.25em' },
h5: { fontSize: '11pt', marginTop: '0.4em', marginBottom: '0.2em' },
h6: { fontSize: '10pt', marginTop: '0.35em', marginBottom: '0.2em' }
};
function buildHeadingCSS(output) {
if (output === 'stripped') return '';
return Object.entries(COPY_HEADING_STYLES)
.map(([tag, style]) => {
const weight = output === 'quikdown' ? ' font-weight:bold;' : '';
return `${tag} { font-size:${style.fontSize};${weight} line-height:1.25; margin:${style.marginTop} 0 ${style.marginBottom}; }`;
})
.join('\n ');
}
/**
* Copy to clipboard using HTML selection fallback (for Safari)
* Uses div with selection to preserve HTML formatting
* @param {string} html - HTML content to copy
* @returns {boolean} Success status
*/
function copyToClipboard(html) {
let tempDiv;
let result;
try {
// Use a div instead of textarea to preserve HTML formatting
tempDiv = document.createElement('div');
tempDiv.style.position = 'fixed';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '0';
tempDiv.style.width = '1px';
tempDiv.style.height = '1px';
tempDiv.style.overflow = 'hidden';
tempDiv.innerHTML = html;
document.body.appendChild(tempDiv);
// Select the HTML content
const range = document.createRange();
range.selectNodeContents(tempDiv);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// Try to copy
result = document.execCommand('copy');
// Clear selection
selection.removeAllRanges();
} catch (err) {
console.error('Fallback copy failed:', err);
result = false;
} finally {
if (tempDiv && tempDiv.parentNode) {
document.body.removeChild(tempDiv);
}
}
return result;
}
/**
* Convert SVG to PNG blob (based on squibview's implementation)
* @param {SVGElement} svgElement - The SVG element to convert
* @returns {Promise<Blob>} A promise that resolves with the PNG blob
*/
async function svgToPng(svgElement, needsWhiteBackground = false) {
return new Promise((resolve, reject) => {
const svgString = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const scale = 2;
// Check if this is a Mermaid-generated SVG (they don't have explicit width/height attributes)
const isMermaidSvg = svgElement.closest('.mermaid') || svgElement.classList.contains('mermaid');
const hasExplicitDimensions = svgElement.getAttribute('width') && svgElement.getAttribute('height');
let svgWidth, svgHeight;
if (isMermaidSvg || !hasExplicitDimensions) {
// For Mermaid or other generated SVGs, prioritize computed dimensions
svgWidth = svgElement.clientWidth ||
(svgElement.viewBox && svgElement.viewBox.baseVal.width) ||
parseFloat(svgElement.getAttribute('width')) || 400;
svgHeight = svgElement.clientHeight ||
(svgElement.viewBox && svgElement.viewBox.baseVal.height) ||
parseFloat(svgElement.getAttribute('height')) || 300;
} else {
// For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
svgWidth = parseFloat(svgElement.getAttribute('width')) ||
(svgElement.viewBox && svgElement.viewBox.baseVal.width) ||
svgElement.clientWidth || 400;
svgHeight = parseFloat(svgElement.getAttribute('height')) ||
(svgElement.viewBox && svgElement.viewBox.baseVal.height) ||
svgElement.clientHeight || 300;
}
// Ensure the SVG string has explicit dimensions by modifying it if necessary
let modifiedSvgString = svgString;
if (svgWidth && svgHeight) {
// Create a temporary SVG element to modify the serialized string
const tempDiv = document.createElement('div');
tempDiv.innerHTML = svgString;
const tempSvg = tempDiv.querySelector('svg');
if (tempSvg) {
tempSvg.setAttribute('width', svgWidth.toString());
tempSvg.setAttribute('height', svgHeight.toString());
modifiedSvgString = new XMLSerializer().serializeToString(tempSvg);
}
}
canvas.width = svgWidth * scale;
canvas.height = svgHeight * scale;
ctx.scale(scale, scale);
img.onload = () => {
try {
// Add white background for math equations (they often have transparent backgrounds)
if (needsWhiteBackground) {
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, svgWidth, svgHeight);
canvas.toBlob(blob => {
resolve(blob);
}, 'image/png', 1.0);
} catch (err) {
reject(err);
}
};
img.onerror = reject;
// Use data URI instead of blob URL to avoid tainting the canvas
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(modifiedSvgString)}`;
img.src = svgDataUrl;
});
}
/**
* Rasterize a GeoJSON Leaflet map to PNG data URL (following Gem's guide)
* @param {HTMLElement} liveContainer - The live map container element
* @returns {Promise<string|null>} PNG data URL or null if failed
*/
async function rasterizeGeoJSONMap(liveContainer) {
try {
const map = liveContainer._map;
if (!map) {
console.warn('No map found on container');
return null;
}
// Get container dimensions
const mapRect = liveContainer.getBoundingClientRect();
const width = Math.round(mapRect.width);
const height = Math.round(mapRect.height);
if (width === 0 || height === 0) {
console.warn('Map container has zero dimensions');
return null;
}
// Create canvas sized to the map container
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
// Set canvas size with DPR for sharpness
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// White background
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
// 1. Draw tiles from THIS container only
const tiles = liveContainer.querySelectorAll('.leaflet-tile');
const tilePromises = [];
for (const tile of tiles) {
tilePromises.push(new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
// Calculate tile position relative to container
const tileRect = tile.getBoundingClientRect();
const offsetX = tileRect.left - mapRect.left;
const offsetY = tileRect.top - mapRect.top;
// Draw tile at correct position
ctx.drawImage(img, offsetX, offsetY, tileRect.width, tileRect.height);
} catch (err) {
console.warn('Failed to draw tile:', err);
}
resolve();
};
img.onerror = () => {
console.warn('Failed to load tile:', tile.src);
resolve();
};
img.src = tile.src;
}));
}
// Wait for all tiles to load
await Promise.all(tilePromises);
// 2. Draw vector overlays (SVG paths for GeoJSON features)
const svgOverlays = liveContainer.querySelectorAll('svg:not(.leaflet-attribution-flag)');
for (const svg of svgOverlays) {
// Skip attribution/control overlays
if (svg.closest('.leaflet-control')) continue;
try {
const svgRect = svg.getBoundingClientRect();
const offsetX = svgRect.left - mapRect.left;
const offsetY = svgRect.top - mapRect.top;
// Serialize SVG
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svg);
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
// Draw SVG overlay
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, offsetX, offsetY, svgRect.width, svgRect.height);
URL.revokeObjectURL(url);
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG overlay'));
};
img.src = url;
});
} catch (err) {
console.warn('Failed to draw SVG overlay:', err);
}
}
// 3. Draw marker icons if any
const markerIcons = liveContainer.querySelectorAll('.leaflet-marker-icon');
for (const marker of markerIcons) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((resolve) => {
img.onload = () => {
const markerRect = marker.getBoundingClientRect();
const offsetX = markerRect.left - mapRect.left;
const offsetY = markerRect.top - mapRect.top;
ctx.drawImage(img, offsetX, offsetY, markerRect.width, markerRect.height);
resolve();
};
img.onerror = resolve;
img.src = marker.src;
});
} catch (err) {
console.warn('Failed to draw marker icon:', err);
}
}
// Return PNG data URL
return canvas.toDataURL('image/png', 1.0);
} catch (error) {
console.error('Failed to rasterize GeoJSON map:', error);
return null;
}
}
/**
* Get rendered content as rich HTML suitable for clipboard
* @param {HTMLElement} previewPanel - The preview panel element to copy from
* @param {Object} [options={}] - Copy options
* @param {string} [options.output='default'] - Output profile: 'default' (no heading font-weight), 'quikdown' (full styling with heading font-weight), or 'stripped' (minimal styling)
* @returns {Promise<{success: boolean, html?: string, text?: string}>}
*/
async function getRenderedContent(previewPanel, options = {}) {
const output = options.output || 'default';
if (!COPY_OUTPUT_PROFILES.includes(output)) {
throw new Error(`Invalid output profile "${output}". Must be one of: ${COPY_OUTPUT_PROFILES.join(', ')}`);
}
if (!previewPanel) {
throw new Error('No preview panel available');
}
// Check if MathJax needs to render (only if not already rendered)
const mathBlocks = previewPanel.querySelectorAll('.math-display');
if (mathBlocks.length > 0) {
// Check if already rendered (has mjx-container inside)
const needsRendering = Array.from(mathBlocks).some(block => !block.querySelector('mjx-container'));
if (needsRendering && window.MathJax && window.MathJax.typesetPromise) {
try {
await window.MathJax.typesetPromise(Array.from(mathBlocks));
} catch (err) {
console.warn('MathJax typesetting failed:', err);
}
}
}
// Clone the preview panel to avoid modifying the actual DOM
const clone = previewPanel.cloneNode(true);
// Process different fence types for rich copy
try {
// Phase 1: Process basic markdown elements with inline styles
// In 'stripped' mode, skip all inline style application
if (output !== 'stripped') {
// 1.1 Text formatting - add inline styles
clone.querySelectorAll('strong, b').forEach(el => {
el.style.fontWeight = 'bold';
});
clone.querySelectorAll('em, i').forEach(el => {
el.style.fontStyle = 'italic';
});
clone.querySelectorAll('del, s, strike').forEach(el => {
el.style.textDecoration = 'line-through';
});
clone.querySelectorAll('u').forEach(el => {
el.style.textDecoration = 'underline';
});
clone.querySelectorAll('code:not(pre code)').forEach(el => {
el.style.backgroundColor = '#f4f4f4';
el.style.padding = '2px 4px';
el.style.borderRadius = '3px';
el.style.fontFamily = 'monospace';
el.style.fontSize = '0.9em';
});
// 1.2 Block elements - add inline styles
Object.entries(COPY_HEADING_STYLES).forEach(([tag, style]) => {
clone.querySelectorAll(tag).forEach(el => {
el.style.fontSize = style.fontSize;
if (output === 'quikdown') {
el.style.fontWeight = 'bold';
}
el.style.lineHeight = '1.25';
el.style.marginTop = style.marginTop;
el.style.marginBottom = style.marginBottom;
});
});
clone.querySelectorAll('blockquote').forEach(el => {
el.style.borderLeft = '4px solid #ddd';
el.style.marginLeft = '0';
el.style.paddingLeft = '1em';
el.style.color = '#666';
});
clone.querySelectorAll('hr').forEach(el => {
el.style.border = 'none';
el.style.borderTop = '1px solid #ccc';
el.style.margin = '1em 0';
});
// 1.3 Tables - add inline styles
clone.querySelectorAll('table').forEach(table => {
table.style.borderCollapse = 'collapse';
table.style.width = '100%';
table.style.marginBottom = '1em';
});
clone.querySelectorAll('th').forEach(th => {
th.style.border = '1px solid #ccc';
th.style.padding = '8px';
th.style.textAlign = 'left';
th.style.backgroundColor = '#f0f0f0';
th.style.fontWeight = 'bold';
});
clone.querySelectorAll('td').forEach(td => {
td.style.border = '1px solid #ccc';
td.style.padding = '8px';
td.style.textAlign = 'left';
});
// 1.4 Links - add inline styles
clone.querySelectorAll('a').forEach(a => {
a.style.color = '#0066cc';
a.style.textDecoration = 'underline';
});
// Process code blocks - wrap in table and add syntax highlighting colors
clone.querySelectorAll('pre code').forEach(block => {
const pre = block.parentElement;
// Add inline styles for syntax highlighting (GitHub theme colors)
if (block.classList.contains('hljs')) {
// Apply inline styles to all highlight.js elements
block.querySelectorAll('.hljs-keyword').forEach(el => {
el.style.color = '#d73a49';
el.style.fontWeight = 'bold';
});
block.querySelectorAll('.hljs-string').forEach(el => {
el.style.color = '#032f62';
});
block.querySelectorAll('.hljs-number').forEach(el => {
el.style.color = '#005cc5';
});
block.querySelectorAll('.hljs-comment').forEach(el => {
el.style.color = '#6a737d';
el.style.fontStyle = 'italic';
});
block.querySelectorAll('.hljs-function').forEach(el => {
el.style.color = '#6f42c1';
});
block.querySelectorAll('.hljs-class').forEach(el => {
el.style.color = '#6f42c1';
});
block.querySelectorAll('.hljs-title').forEach(el => {
el.style.color = '#6f42c1';
});
block.querySelectorAll('.hljs-built_in').forEach(el => {
el.style.color = '#005cc5';
});
block.querySelectorAll('.hljs-literal').forEach(el => {
el.style.color = '#005cc5';
});
block.querySelectorAll('.hljs-meta').forEach(el => {
el.style.color = '#005cc5';
});
block.querySelectorAll('.hljs-attr').forEach(el => {
el.style.color = '#22863a';
});
block.querySelectorAll('.hljs-variable').forEach(el => {
el.style.color = '#e36209';
});
block.querySelectorAll('.hljs-regexp').forEach(el => {
el.style.color = '#032f62';
});
block.querySelectorAll('.hljs-selector-class').forEach(el => {
el.style.color = '#22863a';
});
block.querySelectorAll('.hljs-selector-id').forEach(el => {
el.style.color = '#6f42c1';
});
block.querySelectorAll('.hljs-selector-tag').forEach(el => {
el.style.color = '#22863a';
});
block.querySelectorAll('.hljs-tag').forEach(el => {
el.style.color = '#22863a';
});
block.querySelectorAll('.hljs-name').forEach(el => {
el.style.color = '#22863a';
});
block.querySelectorAll('.hljs-attribute').forEach(el => {
el.style.color = '#6f42c1';
});
}
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.style.border = 'none';
table.style.marginBottom = '1em';
const tr = document.createElement('tr');
const td = document.createElement('td');
td.style.backgroundColor = '#f7f7f7';
td.style.padding = '12px';
td.style.fontFamily = 'Consolas, Monaco, "Courier New", monospace';
td.style.fontSize = '14px';
td.style.lineHeight = '1.4';
td.style.whiteSpace = 'pre';
td.style.overflowX = 'auto';
td.style.border = '1px solid #ddd';
td.style.borderRadius = '4px';
// Move the formatted code content with inline styles
td.innerHTML = block.innerHTML;
tr.appendChild(td);
table.appendChild(tr);
// Replace the pre element with the table
pre.parentNode.replaceChild(table, pre);
});
} // end if (output !== 'stripped')
// Process images - convert to data URLs and ensure proper dimensions
const images = clone.querySelectorAll('img');
for (const img of images) {
// Ensure image has dimensions for Google Docs compatibility
if (!img.width && img.naturalWidth) {
img.width = img.naturalWidth;
}
if (!img.height && img.naturalHeight) {
img.height = img.naturalHeight;
}
// Set max dimensions to prevent huge images
const maxWidth = 800;
const maxHeight = 600;
if (img.width > maxWidth || img.height > maxHeight) {
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
img.width = Math.round(img.width * scale);
img.height = Math.round(img.height * scale);
}
// Ensure width and height attributes are set
if (img.width) {
img.setAttribute('width', img.width.toString());
img.style.width = img.width + 'px';
}
if (img.height) {
img.setAttribute('height', img.height.toString());
img.style.height = img.height + 'px';
}
// Add v:shapes for Word compatibility
if (!img.getAttribute('v:shapes')) {
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
}
// Skip if already a data URL
if (img.src && !img.src.startsWith('data:')) {
try {
// Try to convert to data URL
const response = await fetch(img.src);
const blob = await response.blob();
// Check if image is too large (Google Docs has limits)
const maxSize = 2 * 1024 * 1024; // 2MB limit for inline images
if (blob.size > maxSize) {
console.warn('Image too large for inline data URL:', img.src, 'Size:', blob.size);
// For large images, we might want to resize or keep the URL
continue;
}
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
img.src = dataUrl;
} catch (err) {
console.warn('Failed to convert image to data URL:', img.src, err);
// Keep original src if conversion fails
}
}
}
// Phase 2: Process fence block types
// 1. Process STL 3D models - convert canvas to image or placeholder
const stlContainers = clone.querySelectorAll('.qde-stl-container');
for (const container of stlContainers) {
try {
// Find the corresponding original container to get the canvas
const containerId = container.dataset.stlId;
const originalContainer = previewPanel.querySelector(`.qde-stl-container[data-stl-id="${containerId}"]`);
if (originalContainer) {
// Look for canvas element in the original container (Three.js WebGL canvas)
const canvas = originalContainer.querySelector('canvas');
if (canvas && canvas.width > 0 && canvas.height > 0) {
try {
// Get Three.js references stored on the container (like squibview)
const renderer = originalContainer._threeRenderer;
const scene = originalContainer._threeScene;
const camera = originalContainer._threeCamera;
// If we have access to the Three.js objects, force render the scene
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
// Try to capture the canvas as an image
const dataUrl = canvas.toDataURL('image/png', 1.0);
const img = document.createElement('img');
img.src = dataUrl;
// Use canvas dimensions for the image
const imgWidth = canvas.width / 2; // Divide by scale factor (2x for retina)
const imgHeight = canvas.height / 2;
// Set both HTML attributes and CSS properties for maximum compatibility
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.style.border = '1px solid #ddd';
img.style.borderRadius = '4px';
img.style.margin = '0.5em 0';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'STL 3D Model';
container.parentNode.replaceChild(img, container);
continue;
} catch (canvasErr) {
console.warn('Failed to convert STL canvas to image (likely WebGL context issue):', canvasErr);
}
} else {
console.warn('No valid canvas found in STL container');
}
} else {
console.warn('Could not find original STL container');
}
} catch (err) {
console.error('Error processing STL container for copy:', err);
}
// Fallback to placeholder if canvas conversion fails
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[STL 3D Model - Interactive content not available in copy]';
container.parentNode.replaceChild(placeholder, container);
}
// 2. Process Mermaid diagrams - convert SVG to PNG
const mermaidContainers = clone.querySelectorAll('.mermaid');
for (const container of mermaidContainers) {
const svg = container.querySelector('svg');
if (svg) {
try {
const pngBlob = await svgToPng(svg);
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(pngBlob);
});
const img = document.createElement('img');
img.src = dataUrl;
// Use the exact same dimension calculation logic as svgToPng (like squibview)
const isMermaidSvg = svg.closest('.mermaid') || svg.classList.contains('mermaid');
const hasExplicitDimensions = svg.getAttribute('width') && svg.getAttribute('height');
let imgWidth, imgHeight;
if (isMermaidSvg || !hasExplicitDimensions) {
// For Mermaid or other generated SVGs, prioritize computed dimensions
imgWidth = svg.clientWidth ||
(svg.viewBox && svg.viewBox.baseVal.width) ||
parseFloat(svg.getAttribute('width')) || 400;
imgHeight = svg.clientHeight ||
(svg.viewBox && svg.viewBox.baseVal.height) ||
parseFloat(svg.getAttribute('height')) || 300;
} else {
// For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
imgWidth = parseFloat(svg.getAttribute('width')) ||
(svg.viewBox && svg.viewBox.baseVal.width) ||
svg.clientWidth || 400;
imgHeight = parseFloat(svg.getAttribute('height')) ||
(svg.viewBox && svg.viewBox.baseVal.height) ||
svg.clientHeight || 300;
}
// Set both HTML attributes and CSS properties for maximum compatibility (like squibview)
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none'; // Prevent CSS from constraining the image
img.style.maxHeight = 'none';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'Mermaid Diagram';
container.parentNode.replaceChild(img, container);
} catch (err) {
console.warn('Failed to convert Mermaid diagram:', err);
// Fallback: leave as SVG
}
}
}
// 2b. Process ABC music notation — convert SVG to PNG
// ABCJS renders responsive SVGs (width="100%", no height) whose
// clientHeight reflects the container, not the content. Use the
// viewBox aspect ratio to derive the correct image height.
const abcContainers = clone.querySelectorAll('.qde-abc-container');
for (const container of abcContainers) {
const svg = container.querySelector('svg');
if (svg) {
try {
const pngBlob = await svgToPng(svg);
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(pngBlob);
});
const img = document.createElement('img');
img.src = dataUrl;
// Derive dimensions from viewBox (accurate) rather than
// clientWidth/Height (stretched by container)
const vb = svg.viewBox && svg.viewBox.baseVal;
let imgWidth, imgHeight;
if (vb && vb.width && vb.height) {
const aspect = vb.width / vb.height;
imgWidth = svg.clientWidth || vb.width;
imgHeight = Math.round(imgWidth / aspect);
} else {
imgWidth = svg.clientWidth || parseFloat(svg.getAttribute('width')) || 400;
imgHeight = svg.clientHeight || parseFloat(svg.getAttribute('height')) || 200;
}
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'Music Notation';
container.parentNode.replaceChild(img, container);
} catch (err) {
console.warn('Failed to convert ABC notation:', err);
}
} else {
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[Music Notation - Interactive content not available in copy]';
container.parentNode.replaceChild(placeholder, container);
}
}
// 2c. Process Vega charts — convert SVG to PNG
const vegaContainers = clone.querySelectorAll('.qde-vega-container');
for (const container of vegaContainers) {
const svg = container.querySelector('svg');
if (svg) {
try {
const pngBlob = await svgToPng(svg);
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(pngBlob);
});
const img = document.createElement('img');
img.src = dataUrl;
const imgWidth = svg.clientWidth || parseFloat(svg.getAttribute('width')) || 400;
const imgHeight = svg.clientHeight || parseFloat(svg.getAttribute('height')) || 300;
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'Data Visualization';
container.parentNode.replaceChild(img, container);
} catch (err) {
console.warn('Failed to convert Vega chart:', err);
}
} else {
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[Vega Chart - Interactive content not available in copy]';
container.parentNode.replaceChild(placeholder, container);
}
}
// 3. Process Chart.js charts - convert canvas to image
const chartContainers = clone.querySelectorAll('.qde-chart-container');
for (const container of chartContainers) {
try {
const containerId = container.dataset.chartId;
const originalContainer = previewPanel.querySelector(`.qde-chart-container[data-chart-id="${containerId}"]`);
if (originalContainer) {
const canvas = originalContainer.querySelector('canvas');
if (canvas && canvas.width > 0 && canvas.height > 0) {
try {
const dataUrl = canvas.toDataURL('image/png', 1.0);
const img = document.createElement('img');
img.src = dataUrl;
// Use canvas dimensions for the image
const imgWidth = canvas.width;
const imgHeight = canvas.height;
// Set both HTML attributes and CSS properties for maximum compatibility
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.style.margin = '0.5em 0';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'Chart';
container.parentNode.replaceChild(img, container);
continue;
} catch (canvasErr) {
console.warn('Failed to convert chart canvas to image:', canvasErr);
}
}
}
} catch (err) {
console.warn('Error processing chart container:', err);
}
// Fallback to placeholder
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[Chart - Interactive content not available in copy]';
container.parentNode.replaceChild(placeholder, container);
}
// 4. Process SVG fenced blocks - convert to PNG
const svgContainers = clone.querySelectorAll('.qde-svg-container svg');
for (const svg of svgContainers) {
try {
const pngBlob = await svgToPng(svg);
const dataUrl = await new Promise(resolve => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(pngBlob);
});
const img = document.createElement('img');
img.src = dataUrl;
// Calculate dimensions for the SVG
const hasExplicitDimensions = svg.getAttribute('width') && svg.getAttribute('height');
let imgWidth, imgHeight;
if (hasExplicitDimensions) {
// For explicit SVGs (like fenced SVG blocks), prioritize explicit attributes
imgWidth = parseFloat(svg.getAttribute('width')) ||
(svg.viewBox && svg.viewBox.baseVal.width) ||
svg.clientWidth || 400;
imgHeight = parseFloat(svg.getAttribute('height')) ||
(svg.viewBox && svg.viewBox.baseVal.height) ||
svg.clientHeight || 300;
} else {
// For generated SVGs, prioritize computed dimensions
imgWidth = svg.clientWidth ||
(svg.viewBox && svg.viewBox.baseVal.width) ||
parseFloat(svg.getAttribute('width')) || 400;
imgHeight = svg.clientHeight ||
(svg.viewBox && svg.viewBox.baseVal.height) ||
parseFloat(svg.getAttribute('height')) || 300;
}
// Set both HTML attributes and CSS properties for maximum compatibility
img.width = imgWidth;
img.height = imgHeight;
img.setAttribute('width', imgWidth.toString());
img.setAttribute('height', imgHeight.toString());
img.style.width = imgWidth + 'px';
img.style.height = imgHeight + 'px';
img.style.maxWidth = 'none'; // Prevent CSS from constraining the image
img.style.maxHeight = 'none';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'SVG Image';
svg.parentNode.replaceChild(img, svg);
} catch (err) {
console.warn('Failed to convert SVG to image:', err);
// Leave as SVG if conversion fails
}
}
// 5. Process Math equations - convert to PNG images (exactly like SquibView)
const mathElements = Array.from(clone.querySelectorAll('.math-display'));
if (mathElements.length > 0) {
for (const mathEl of mathElements) {
try {
// Find SVG inside the math element (MathJax creates it)
const svg = mathEl.querySelector('svg');
if (!svg) {
console.warn('No SVG found in math element, skipping');
continue;
}
// Convert SVG to PNG data URL (exactly like SquibView)
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svg);
const svgBlob = new Blob([svgStr], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
const dataUrl = await new Promise((resolve, reject) => {
img.onload = function () {
const canvas = document.createElement('canvas');
// Determine SVG dimensions robustly (exactly like SquibView)
let width, height;
try {
// First try baseVal.value (works for absolute units)
width = svg.width.baseVal.value;
height = svg.height.baseVal.value;
} catch (_e) {
// Fallback for relative units - use viewBox or rendered size
if (svg.viewBox && svg.viewBox.baseVal) {
width = svg.viewBox.baseVal.width;
height = svg.viewBox.baseVal.height;
} else {
// Use the natural size of the loaded image
width = img.naturalWidth || img.width || 200;
height = img.naturalHeight || img.height || 50;
}
}
// Scale down for much smaller paste sizes
const targetMaxWidth = 150; // Further reduced
const targetMaxHeight = 45; // Further reduced
// Apply aggressive downsizing for MathJax SVGs
let scaleFactor = 0.04; // Further reduced for smaller output
const scaledWidth = width * scaleFactor;
const scaledHeight = height * scaleFactor;
// If still too large after base scaling, scale down further
if (scaledWidth > targetMaxWidth || scaledHeight > targetMaxHeight) {
const scaleX = targetMaxWidth / scaledWidth;
const scaleY = targetMaxHeight / scaledHeight;
scaleFactor *= Math.min(scaleX, scaleY);
}
width *= scaleFactor;
height *= scaleFactor;
// Use higher DPR for crisp rendering at smaller sizes
const dpr = 2; // Fixed 2x for consistent quality
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// White background
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, width, height);
// Draw the SVG image at logical size
ctx.drawImage(img, 0, 0, width, height);
// Clean up URL
URL.revokeObjectURL(url);
// Return data URL
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load SVG image'));
};
img.src = url;
});
// Replace math element with img tag containing the PNG data URL
const imgElement = document.createElement('img');
imgElement.src = dataUrl;
// Extract dimensions from the data URL canvas
const img2 = new Image();
img2.src = dataUrl;
await new Promise((resolve) => {
img2.onload = resolve;
img2.onerror = resolve;
setTimeout(resolve, 100); // Timeout fallback
});
// Set explicit dimensions (accounting for DPR)
const displayWidth = img2.naturalWidth / 2; // Divide by DPR
const displayHeight = img2.naturalHeight / 2;
imgElement.width = displayWidth;
imgElement.height = displayHeight;
imgElement.style.cssText = `display:inline-block;margin:0.5em 0;width:${displayWidth}px;height:${displayHeight}px;vertical-align:middle;`;
imgElement.alt = 'Math equation';
mathEl.parentNode.replaceChild(imgElement, mathEl);
} catch (error) {
console.error('Failed to convert math element to image:', error);
// Keep the original element if conversion fails
}
}
}
// 2. Process GeoJSON maps - convert to static images (following Gem's guide)
const geojsonContainers = clone.querySelectorAll('.geojson-container');
if (geojsonContainers.length > 0) {
for (const clonedContainer of geojsonContainers) {
try {
// Find the corresponding live container by matching data-original-source
const originalSource = clonedContainer.getAttribute('data-original-source');
if (!originalSource) {
console.warn('No original source found for GeoJSON container');
continue;
}
// Find live container with same source
let liveContainer = null;
const allLiveContainers = previewPanel.querySelectorAll('.geojson-container');
for (const candidate of allLiveContainers) {
if (candidate.getAttribute('data-original-source') === originalSource) {
liveContainer = candidate;
break;
}
}
if (!liveContainer) {
console.warn('Could not find live GeoJSON container');
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
continue;
}
// Check if map is ready
const map = liveContainer._map;
if (!map) {
console.warn('Map not initialized yet');
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[GeoJSON Map - Still loading]';
clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
continue;
}
// Rasterize the map to PNG
const dataUrl = await rasterizeGeoJSONMap(liveContainer);
if (dataUrl) {
// Replace with image
const img = document.createElement('img');
img.src = dataUrl;
img.style.cssText = 'width: 100%; height: 300px; border: 1px solid #ddd; border-radius: 4px; margin: 0.5em 0;';
img.alt = 'GeoJSON Map';
clonedContainer.parentNode.replaceChild(img, clonedContainer);
} else {
// Fallback placeholder
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
}
} catch (error) {
console.error('Failed to process GeoJSON container:', error);
// Replace with placeholder
const placeholder = document.createElement('div');
placeholder.style.cssText = 'padding: 12px; background-color: #f0f0f0; border: 1px solid #ccc; text-align: center; margin: 0.5em 0; border-radius: 4px;';
placeholder.textContent = '[GeoJSON Map - Interactive content not available in copy]';
clonedContainer.parentNode.replaceChild(placeholder, clonedContainer);
}
}
}
// 6. Process GeoJSON/Leaflet maps - capture as single image (compose tiles + overlays)
const mapContainers = clone.querySelectorAll('[data-qd-lang="geojson"]');
for (const container of mapContainers) {
try {
const containerId = container.id;
const originalContainer = containerId ? previewPanel.querySelector(`#${containerId}`) : null;
if (!originalContainer) continue;
const leafletContainer = originalContainer.querySelector('.leaflet-container');
if (!leafletContainer) continue;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const width = leafletContainer.clientWidth || 600;
const height = leafletContainer.clientHeight || 400;
const canvas = document.createElement('canvas');
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, width, height);
const leafRect = leafletContainer.getBoundingClientRect();
// Draw tiles (snap to integer pixels to avoid seams)
const tiles = Array.from(leafletContainer.querySelectorAll('img.leaflet-tile'));
for (const tile of tiles) {
try {
const r = tile.getBoundingClientRect();
const x = Math.round(r.left - leafRect.left);
const y = Math.round(r.top - leafRect.top);
const w = Math.round(r.width);
const h = Math.round(r.height);
const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
const style = window.getComputedStyle(tile);
if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
ctx.drawImage(tile, x, y, w + 1, h + 1);
}
} catch (_e) {
console.warn('Failed to draw tile:', _e);
}
}
// Draw SVG overlays (paths, markers)
const overlaySvgs = originalContainer.querySelectorAll('.leaflet-overlay-pane svg');
for (const svg of overlaySvgs) {
try {
const svgStr = new XMLSerializer().serializeToString(svg);
const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgStr);
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.onerror = resolve; img.src = dataUrl; });
const r = svg.getBoundingClientRect();
const x = Math.round(r.left - leafRect.left);
const y = Math.round(r.top - leafRect.top);
const w = Math.round(r.width);
const h = Math.round(r.height);
const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
if (w > 0 && h > 0 && overlaps) ctx.drawImage(img, x, y, w, h);
} catch (_e) {
console.warn('Failed to draw overlay SVG:', _e);
}
}
// Draw marker icons (PNG/SVG img elements)
const markerIcons = originalContainer.querySelectorAll('.leaflet-marker-pane img.leaflet-marker-icon');
for (const icon of markerIcons) {
try {
const r = icon.getBoundingClientRect();
const x = Math.round(r.left - leafRect.left);
const y = Math.round(r.top - leafRect.top);
const w = Math.round(r.width);
const h = Math.round(r.height);
const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
const style = window.getComputedStyle(icon);
if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
ctx.drawImage(icon, x, y, w, h);
}
} catch (_e) {
console.warn('Failed to draw marker icon:', _e);
}
}
// Try to produce a data URL (may fail if canvas tainted by CORS tiles)
let mapDataUrl = '';
try {
mapDataUrl = canvas.toDataURL('image/png', 1.0);
} catch (_e) {
console.warn('Map canvas tainted; falling back to placeholder');
}
const img = document.createElement('img');
if (mapDataUrl) {
img.src = mapDataUrl;
img.width = width;
img.height = height;
img.setAttribute('width', String(width));
img.setAttribute('height', String(height));
img.style.width = width + 'px';
img.style.height = height + 'px';
img.style.display = 'block';
img.style.border = '1px solid #ddd';
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
img.alt = 'Map';
} else {
img.alt = 'Map';
img.style.width = width + 'px';
img.style.height = height + 'px';
img.style.border = '1px solid #ddd';
img.style.backgroundColor = '#f0f0f0';
}
container.parentNode.replaceChild(img, container);
} catch (err) {
console.warn('Failed to process map container:', err);
}
}
// 7. Process HTML fence blocks - render the HTML content and process images
const htmlContainers = clone.querySelectorAll('.qde-html-container');
for (const container of htmlContainers) {
try {
// Get the original source HTML
const source = container.getAttribute('data-qd-source');
// Check if there's a pre element (fallback display) or actual HTML content
const pre = container.querySelector('pre');
if (source) {
// Parse the source HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = source;
// Sanitize: remove script elements and on* event handlers
tempDiv.querySelectorAll('script').forEach(s => s.remove());
tempDiv.querySelectorAll('*').forEach(el => {
for (const attr of Array.from(el.attributes)) {
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
}
});
// Process all images in the HTML block
const htmlImages = tempDiv.querySelectorAll('img');
for (const img of htmlImages) {
// Preserve original dimensions from HTML attributes
const widthAttr = img.getAttribute('width');
const heightAttr = img.getAttribute('height');
if (widthAttr) {
img.width = parseInt(widthAttr);
img.style.width = widthAttr.includes('%') ? widthAttr : `${img.width}px`;
}
if (heightAttr) {
img.height = parseInt(heightAttr);
img.style.height = heightAttr.includes('%') ? heightAttr : `${img.height}px`;
}
// Convert to data URL using canvas (like squibview)
if (img.src && !img.src.startsWith('data:')) {
try {
// Use canvas to convert image to data URL (avoids CORS issues)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Create new image and wait for it to load
const tempImg = new Image();
tempImg.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
tempImg.onload = function() {
// Calculate dimensions preserving aspect ratio
let displayWidth = 0;
let displayHeight = 0;
// Use the width specified in HTML (e.g. width="250")
if (widthAttr && !widthAttr.includes('%')) {
displayWidth = parseInt(widthAttr);
}
// Use the height if specified
if (heightAttr && !heightAttr.includes('%')) {
displayHeight = parseInt(heightAttr);
}
// If only width is specified, calculate height based on aspect ratio
if (displayWidth > 0 && displayHeight === 0) {
if (tempImg.naturalWidth > 0) {
const aspectRatio = tempImg.naturalHeight / tempImg.naturalWidth;
displayHeight = Math.round(displayWidth * aspectRatio);
}
}
// If only height is specified, calculate width based on aspect ratio
else if (displayHeight > 0 && displayWidth === 0) {
if (tempImg.naturalHeight > 0) {
const aspectRatio = tempImg.naturalWidth / tempImg.naturalHeight;
displayWidth = Math.round(displayHeight * aspectRatio);
}
}
// If neither specified, use natural dimensions
else if (displayWidth === 0 && displayHeight === 0) {
displayWidth = tempImg.naturalWidth || 250;
displayHeight = tempImg.naturalHeight || 200;
}
canvas.width = displayWidth;
canvas.height = displayHeight;
// Draw image to canvas
ctx.drawImage(tempImg, 0, 0, displayWidth, displayHeight);
// Convert to data URL
const dataUrl = canvas.toDataURL('image/png', 1.0);
// Update original image
img.src = dataUrl;
img.width = displayWidth;
img.height = displayHeight;
img.setAttribute('width', displayWidth.toString());
img.setAttribute('height', displayHeight.toString());
img.style.width = displayWidth + 'px';
img.style.height = displayHeight + 'px';
resolve();
};
tempImg.onerror = function() {
console.warn('Failed to load HTML fence image:', img.src);
reject(new Error('Image load failed'));
};
// Set source - resolve relative paths
if (img.src.startsWith('http') || img.src.startsWith('//')) {
tempImg.src = img.src;
} else {
// Relative path - let browser resolve it
const absoluteImg = new Image();
absoluteImg.src = img.src;
tempImg.src = absoluteImg.src;
}
});
} catch (err) {
console.warn('Failed to convert HTML fence image:', img.src, err);
}
}
// Add v:shapes for Word compatibility
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
}
// Replace container content with processed HTML (whether it had pre or not)
container.innerHTML = tempDiv.innerHTML;
} else if (!pre) {
// Container has rendered HTML already, process its images directly
const htmlImages = container.querySelectorAll('img');
for (const img of htmlImages) {
// Same image processing as above
const widthAttr = img.getAttribute('width');
const heightAttr = img.getAttribute('height');
if (widthAttr) {
img.width = parseInt(widthAttr);
img.style.width = widthAttr.includes('%') ? widthAttr : `${img.width}px`;
}
if (heightAttr) {
img.height = parseInt(heightAttr);
img.style.height = heightAttr.includes('%') ? heightAttr : `${img.height}px`;
}
if (img.src && !img.src.startsWith('data:')) {
try {
// Use same canvas approach as above
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const tempImg = new Image();
tempImg.crossOrigin = 'anonymous';
await new Promise((resolve, reject) => {
tempImg.onload = function() {
// Calculate dimensions preserving aspect ratio
let displayWidth = img.width || 0;
let displayHeight = img.height || 0;
// If only width is specified, calculate height based on aspect ratio
if (displayWidth && !displayHeight) {
const aspectRatio = tempImg.naturalHeight / tempImg.naturalWidth;
displayHeight = Math.round(displayWidth * aspectRatio);
}
// If only height is specified, calculate width based on aspect ratio
else if (displayHeight && !displayWidth) {
const aspectRatio = tempImg.naturalWidth / tempImg.naturalHeight;
displayWidth = Math.round(displayHeight * aspectRatio);
}
// If neither specified, use natural dimensions
else if (!displayWidth && !displayHeight) {
displayWidth = tempImg.naturalWidth || 250;
displayHeight = tempImg.naturalHeight || Math.round(250 * (tempImg.naturalHeight / tempImg.naturalWidth));
}
canvas.width = displayWidth;
canvas.height = displayHeight;
ctx.drawImage(tempImg, 0, 0, displayWidth, displayHeight);
const dataUrl = canvas.toDataURL('image/png', 1.0);
img.src = dataUrl;
img.width = displayWidth;
img.height = displayHeight;
img.setAttribute('width', displayWidth.toString());
img.setAttribute('height', displayHeight.toString());
img.style.width = displayWidth + 'px';
img.style.height = displayHeight + 'px';
resolve();
};
tempImg.onerror = function() {
console.warn('Failed to load HTML fence image:', img.src);
reject(new Error('Image load failed'));
};
if (img.src.startsWith('http') || img.src.startsWith('//')) {
tempImg.src = img.src;
} else {
const absoluteImg = new Image();
absoluteImg.src = img.src;
tempImg.src = absoluteImg.src;
}
});
} catch (err) {
console.warn('Failed to convert HTML fence image:', img.src, err);
}
}
img.setAttribute('v:shapes', 'image' + Math.random().toString(36).substr(2, 9));
}
}
} catch (err) {
console.warn('Failed to process HTML container:', err);
}
}
// 8. Tables are already HTML tables from the built-in renderer
// No processing needed
// Wrap in proper HTML structure for rich text editors
const fragment = clone.innerHTML;
const headingCSS = buildHeadingCSS(output);
const cssBlock = output === 'stripped'
? '/* Minimal styles */\n img { max-width: 100%; height: auto; }'
: `${headingCSS}
/* Table styling */
table { border-collapse: collapse; width: 100%; margin-bottom: 1em; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
th { background-color: #f0f0f0; font-weight: bold; }
/* Code block styling */
pre { background-color: #f4f4f4; padding: 1em; border-radius: 4px; overflow-x: auto; }
code { font-family: monospace; background-color: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; }
/* Image handling */
img { display: block; max-width: 100%; height: auto; margin: 0.5em 0; }
/* Blockquote */
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 1em; color: #666; }`;
const htmlContent = `
<!DOCTYPE html>
<html xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:w="urn:schemas-microsoft-com:office:word">
<head>
<meta charset="utf-8">
<style>
${cssBlock}
/* Math equations centered like squibview */
.math-display { text-align: center; margin: 1em 0; }
.math-display img { display: inline-block; margin: 0 auto; }
</style>
</head>
<body><!--StartFragment-->${fragment}<!--EndFragment--></body>
</html>`;
// Get plain text version
const text = clone.textContent || clone.innerText || '';
// Get platform for clipboard strategy (like squibview)
const platform = getPlatform();
if (platform === 'macos') {
// macOS approach (like squibview)
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' })
})
]);
return { success: true, html: htmlContent, text };
} catch (modernErr) {
console.warn('Modern clipboard API failed, trying Safari fallback:', modernErr);
// Safari fallback (selection-based HTML of fragment)
if (copyToClipboard(fragment)) {
return { success: true, html: htmlContent, text };
}
throw new Error('Fallback copy failed', { cause: modernErr });
}
} else {
// Windows/Linux approach (like squibview)
const tempDiv = document.createElement('div');
tempDiv.style.position = 'fixed';
tempDiv.style.left = '-9999px';
tempDiv.style.top = '0';
// Use fragment for selection-based fallback copy
tempDiv.innerHTML = fragment;
document.body.appendChild(tempDiv);
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' })
})
]);
return { success: true, html: htmlContent, text };
} catch (modernErr) {
console.warn('Modern clipboard API failed, trying execCommand fallback:', modernErr);
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(tempDiv);
selection.removeAllRanges();
selection.addRange(range);
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Fallback copy failed', { cause: modernErr });
}
return { success: true, html: htmlContent, text };
} finally {
if (tempDiv && tempDiv.parentNode) {
document.body.removeChild(tempDiv);
}
}
}
} catch (err) {
console.error('Failed to copy rendered content:', err);
throw err;
}
}
/**
* Quikdown Editor - A drop-in markdown editor control
* @license BSD-2-Clause
*/
/** Build-time version stamp (injected by rollup replaceVersion plugin) */
const quikdownEditorVersion = '1.2.22';
/**
* Curated safe HTML tag whitelist.
* Pass to quikdown's `allow_unsafe_html` option to allow these tags
* through while escaping everything else. Callers can use this as-is,
* pass a subset, or build their own object — any object whose keys are
* lowercase tag names works.
*
* @example
* // Use the curated list
* quikdown(md, { allow_unsafe_html: QuikdownEditor.SAFE_HTML_TAGS });
*
* // Or a minimal subset
* quikdown(md, { allow_unsafe_html: { img: 1, a: 1, br: 1 } });
*
* // Or an array (converted internally)
* quikdown(md, { allow_unsafe_html: ['img', 'a', 'br'] });
*/
const SAFE_HTML_TAGS = {
b:1, i:1, em:1, strong:1, del:1, s:1, u:1, mark:1, sup:1, sub:1,
kbd:1, abbr:1, var:1, samp:1, cite:1, small:1, ins:1, dfn:1,
ruby:1, rt:1, rp:1, time:1, wbr:1,
img:1, picture:1, source:1, video:1, audio:1, figure:1, figcaption:1,
a:1, br:1, hr:1,
div:1, span:1, p:1, details:1, summary:1,
section:1, article:1, aside:1, header:1, footer:1, nav:1, main:1,
table:1, thead:1, tbody:1, tfoot:1, tr:1, th:1, td:1, caption:1, col:1, colgroup:1,
ul:1, ol:1, li:1, dl:1, dt:1, dd:1,
h1:1, h2:1, h3:1, h4:1, h5:1, h6:1,
blockquote:1, pre:1, code:1
};
// Default options
const DEFAULT_OPTIONS = {
mode: 'split', // 'source' | 'preview' | 'split'
showToolbar: true,
showRemoveHR: false, // Show button to remove horizontal rules (---)
showLazyLinefeeds: false, // Show button to convert lazy linefeeds
theme: 'auto', // 'light' | 'dark' | 'auto'
lazy_linefeeds: false,
inline_styles: false, // Use CSS classes (false) or inline styles (true)
debounceDelay: 20, // Reduced from 100ms for better responsiveness
placeholder: 'Start typing markdown...',
plugins: {
highlightjs: false,
mermaid: false
},
/**
* Preload fence-rendering libraries at construction time so the FIRST
* encounter with a fence type renders instantly (no lazy load delay).
*
* Accepts:
* - 'all' — preload every known library
* - ['highlightjs','mermaid','math',
* 'geojson','stl'] — preload specific libraries
* - [{ name: 'mylib', script: 'https://...', css: '...' }]
* — preload an arbitrary library
*
* Without this, fence libraries are loaded on demand the first time their
* fence type is encountered. That keeps the editor lightweight, but the
* first SVG/Mermaid/Math/GeoJSON/STL fence will show "loading..." for a
* moment. Set `preloadFences` if you want zero-delay rendering — at the
* cost of a few hundred KB of upfront network.
*
* Developer's choice. The editor itself is still ~70 KB minified;
* `preloadFences` only affects the OPTIONAL fence renderers.
*/
preloadFences: null,
customFences: {}, // { 'language': (code, lang) => html }
enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
showUndoRedo: false, // Show undo/redo toolbar buttons
undoStackSize: 100, // Maximum number of undo states to keep
allowUnsafeHTML: false, // false | 'limited' | true — controls HTML passthrough
showAllowUnsafeHTML: false, // Show toolbar button to cycle HTML mode
allowExternalFetch: true, // Allow fence renderers to fetch external resources (CDN, tiles, etc.)
reference_links: false, // Enable reference-style links [text][id]
footnotes: false // Enable footnotes [^id]
};
// Library catalog used by preloadFences. Each entry knows how to:
// - check if the library is already on the page (so we don't double-load)
// - load it via script (and optional CSS)
const FENCE_LIBRARIES = {
highlightjs: {
check: () => typeof window.hljs !== 'undefined',
script: 'https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js',
css: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css',
cssDark: 'https://unpkg.com/@highlightjs/cdn-assets/styles/github-dark.min.css'
},
mermaid: {
check: () => typeof window.mermaid !== 'undefined',
script: 'https://unpkg.com/mermaid/dist/mermaid.min.js',
afterLoad: () => {
if (window.mermaid) window.mermaid.initialize({ startOnLoad: false });
}
},
math: {
check: () => typeof window.MathJax !== 'undefined',
script: 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js',
beforeLoad: () => {
// Configure MathJax before loading (must be set on window before script runs)
// Must match the config in ensureMathJaxLoaded() for consistent behavior
if (!window.MathJax) {
window.MathJax = {
loader: { load: ['input/tex', 'output/svg'] },
tex: {
packages: { '[+]': ['ams'] },
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true,
processEnvironments: true
},
options: {
renderActions: { addMenu: [] },
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
},
svg: {
fontCache: 'none' // self-contained SVGs (required for copy-rendered)
},
startup: { typeset: false }
};
}
}
},
geojson: {
check: () => typeof window.L !== 'undefined',
script: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
css: 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
},
stl: {
check: () => typeof window.THREE !== 'undefined',
script: 'https://unpkg.com/three@0.147.0/build/three.min.js'
},
abc: {
check: () => typeof window.ABCJS !== 'undefined',
script: 'https://cdn.jsdelivr.net/npm/abcjs@6/dist/abcjs-basic-min.js'
},
music: {
check: () => typeof window.ABCJS !== 'undefined',
script: 'https://cdn.jsdelivr.net/npm/abcjs@6/dist/abcjs-basic-min.js'
},
vega: {
check: () => typeof window.vegaEmbed !== 'undefined',
scripts: [
'https://cdn.jsdelivr.net/npm/vega@5',
'https://cdn.jsdelivr.net/npm/vega-lite@5',
'https://cdn.jsdelivr.net/npm/vega-embed@6'
]
}
};
/**
* Quikdown Editor - A complete markdown editing solution
*/
class QuikdownEditor {
constructor(container, options = {}) {
// Resolve container
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
throw new Error('QuikdownEditor: Invalid container');
}
// Merge options
this.options = { ...DEFAULT_OPTIONS, ...options };
// State
this._markdown = '';
this._html = '';
this.currentMode = this.options.mode;
this.updateTimer = null;
// Undo/redo state
this._undoStack = [];
this._redoStack = [];
this._isUndoRedo = false;
// Instance-level copy of fence libraries so custom entries
// don't leak across editor instances.
this._fenceLibraries = { ...FENCE_LIBRARIES };
// Initialize
this.initPromise = this.init();
}
/**
* Initialize the editor
*/
async init() {
// Load plugins if requested
await this.loadPlugins();
// Build UI
this.buildUI();
// Attach event listeners
this.attachEvents();
// Apply initial theme
this.applyTheme();
// Set initial mode
this.setMode(this.currentMode);
// Set initial content if provided
if (this.options.initialContent) {
this.setMarkdown(this.options.initialContent);
}
}
/**
* Build the editor UI
*/
buildUI() {
// Clear container
this.container.innerHTML = '';
// Add editor class
this.container.classList.add('qde-container');
// Create toolbar if enabled
if (this.options.showToolbar) {
this.toolbar = this.createToolbar();
this.container.appendChild(this.toolbar);
}
// Create editor area
this.editorArea = document.createElement('div');
this.editorArea.className = 'qde-editor';
// Create source panel
this.sourcePanel = document.createElement('div');
this.sourcePanel.className = 'qde-source';
this.sourceTextarea = document.createElement('textarea');
this.sourceTextarea.className = 'qde-textarea';
this.sourceTextarea.spellcheck = false;
this.sourceTextarea.placeholder = this.options.placeholder;
this.sourcePanel.appendChild(this.sourceTextarea);
// Create preview panel
this.previewPanel = document.createElement('div');
this.previewPanel.className = 'qde-preview';
this.previewPanel.contentEditable = true;
this.previewPanel.spellcheck = false;
// Add panels to editor
this.editorArea.appendChild(this.sourcePanel);
this.editorArea.appendChild(this.previewPanel);
this.container.appendChild(this.editorArea);
// Add built-in styles if not already present
this.injectStyles();
}
/**
* Create toolbar
*/
createToolbar() {
const toolbar = document.createElement('div');
toolbar.className = 'qde-toolbar';
// Mode buttons
const modes = ['source', 'split', 'preview'];
const modeLabels = { source: 'Source', split: 'Split', preview: 'Rendered' };
modes.forEach(mode => {
const btn = document.createElement('button');
btn.className = 'qde-btn';
btn.dataset.mode = mode;
btn.textContent = modeLabels[mode];
btn.title = `Switch to ${modeLabels[mode]} view`;
toolbar.appendChild(btn);
});
// Mobile split toggle (hidden by default, shown via CSS on narrow viewports)
const splitToggle = document.createElement('button');
splitToggle.className = 'qde-btn qde-split-toggle';
splitToggle.textContent = 'Preview';
splitToggle.title = 'Toggle between source and preview in split mode';
toolbar.appendChild(splitToggle);
// Undo/Redo buttons (if enabled)
if (this.options.showUndoRedo) {
const undoBtn = document.createElement('button');
undoBtn.className = 'qde-btn disabled';
undoBtn.dataset.action = 'undo';
undoBtn.textContent = 'Undo';
undoBtn.title = 'Undo (Ctrl+Z)';
toolbar.appendChild(undoBtn);
const redoBtn = document.createElement('button');
redoBtn.className = 'qde-btn disabled';
redoBtn.dataset.action = 'redo';
redoBtn.textContent = 'Redo';
redoBtn.title = 'Redo (Ctrl+Shift+Z / Ctrl+Y)';
toolbar.appendChild(redoBtn);
}
// Spacer
const spacer = document.createElement('span');
spacer.className = 'qde-spacer';
toolbar.appendChild(spacer);
// Copy buttons
const copyButtons = [
{ action: 'copy-markdown', text: 'Copy MD', title: 'Copy markdown to clipboard' },
{ action: 'copy-html', text: 'Copy HTML', title: 'Copy HTML to clipboard' },
{ action: 'copy-rendered', text: 'Copy Rendered', title: 'Copy rich text to clipboard' }
];
copyButtons.forEach(({ action, text, title }) => {
const btn = document.createElement('button');
btn.className = 'qde-btn';
btn.dataset.action = action;
btn.textContent = text;
btn.title = title;
toolbar.appendChild(btn);
});
// Remove HR button (if enabled)
if (this.options.showRemoveHR) {
const removeHRBtn = document.createElement('button');
removeHRBtn.className = 'qde-btn';
removeHRBtn.dataset.action = 'remove-hr';
removeHRBtn.textContent = 'Remove HR';
removeHRBtn.title = 'Remove all horizontal rules (---) from markdown';
toolbar.appendChild(removeHRBtn);
}
// Lazy linefeeds button (if enabled)
if (this.options.showLazyLinefeeds) {
const lazyLFBtn = document.createElement('button');
lazyLFBtn.className = 'qde-btn';
lazyLFBtn.dataset.action = 'lazy-linefeeds';
lazyLFBtn.textContent = 'Fix Linefeeds';
lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
toolbar.appendChild(lazyLFBtn);
}
// Allow unsafe HTML toggle button (if enabled)
if (this.options.showAllowUnsafeHTML) {
const htmlModeBtn = document.createElement('button');
htmlModeBtn.className = 'qde-btn';
htmlModeBtn.dataset.action = 'toggle-html-mode';
htmlModeBtn.textContent = this._getHtmlModeLabel(this.options.allowUnsafeHTML);
htmlModeBtn.title = this._getHtmlModeTooltip(this.options.allowUnsafeHTML);
toolbar.appendChild(htmlModeBtn);
}
return toolbar;
}
/**
* Inject built-in styles
*/
injectStyles() {
if (document.getElementById('qde-styles')) return;
const style = document.createElement('style');
style.id = 'qde-styles';
style.textContent = `
.qde-container {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
background: white;
color: #1f2937;
}
.qde-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 8px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
gap: 4px;
}
.qde-btn {
padding: 6px 12px;
border: 1px solid #ccc;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.qde-btn:hover {
background: #e9e9e9;
border-color: #999;
}
.qde-btn.active {
background: #007bff;
color: white;
border-color: #0056b3;
}
.qde-btn.disabled {
opacity: 0.4;
pointer-events: none;
}
.qde-btn[data-action="toggle-html-mode"] {
position: relative;
}
.qde-btn[data-action="toggle-html-mode"]:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 5px 10px;
background: #1f2937;
color: #fff;
font-size: 0.75rem;
font-weight: 400;
white-space: nowrap;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
.qde-spacer {
flex: 1;
}
.qde-editor {
display: flex;
flex: 1;
overflow: hidden;
}
.qde-source, .qde-preview {
flex: 1 1 0;
min-width: 0; /* allow flex shrinking below content size */
min-height: 0;
overflow: auto;
padding: 16px;
box-sizing: border-box;
}
.qde-source {
border-right: 1px solid #ddd;
/* Source pane is just a container for the textarea — make it
a positioning context so the textarea can fill it absolutely */
position: relative;
padding: 0; /* textarea brings its own padding */
}
.qde-textarea {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
padding: 16px;
box-sizing: border-box;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
background: transparent;
color: inherit;
/* Wrap long lines so the textarea only scrolls VERTICALLY.
pre-wrap preserves intentional line breaks/whitespace
while soft-wrapping at the right edge. */
white-space: pre-wrap;
word-wrap: break-word;
overflow-x: hidden;
overflow-y: auto;
}
.qde-preview {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.6;
outline: none;
cursor: text; /* Standard text cursor */
overflow-x: hidden; /* never scroll horizontally; clip wide content */
}
/* Code blocks and inline code — self-contained so the editor
does not depend on any external stylesheet for these. */
.qde-preview pre {
background: #f4f4f4;
color: #1f2937;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
margin: 0.6em 0;
font-size: 0.9em;
line-height: 1.5;
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
"Roboto Mono", Consolas, "Courier New", monospace;
}
.qde-preview code {
padding: 2px 4px;
font-size: 0.9em;
border-radius: 3px;
background: #f0f0f0;
color: #1f2937;
font-family: ui-monospace, "SF Mono", Monaco, "Cascadia Code",
"Roboto Mono", Consolas, "Courier New", monospace;
}
.qde-preview pre code {
padding: 0;
font-size: inherit;
border-radius: 0;
background: transparent;
color: inherit;
}
/* Wide fence content (Leaflet maps, large SVGs, STL canvases,
iframes, raw <img>) must never overflow the preview pane */
.qde-preview .geojson-container,
.qde-preview .qde-stl-container,
.qde-preview .qde-svg-container,
.qde-preview .leaflet-container,
.qde-preview iframe,
.qde-preview img,
.qde-preview > svg {
max-width: 100%;
}
.qde-preview img {
display: inline;
}
.qde-preview .leaflet-container { box-sizing: border-box; }
.qde-preview .leaflet-container.qde-offline-map {
background: #b8d4f0;
}
.qde-preview .leaflet-container.qde-offline-map path {
shape-rendering: geometricPrecision;
}
.qde-dark .qde-preview .leaflet-container.qde-offline-map {
background: #1a3048;
}
/* Standard markdown tables (the .quikdown-table class) need to
scroll horizontally inside their own wrapper rather than
making the whole preview pane scroll */
.qde-preview table.quikdown-table,
.qde-preview table.qde-csv-table {
display: block;
max-width: 100%;
overflow-x: auto;
}
/* Fence-specific styles */
.qde-svg-container {
max-width: 100%;
overflow: auto;
}
.qde-svg-container svg {
max-width: 100%;
height: auto;
}
.qde-html-container {
/* HTML containers inherit background */
margin: 12px 0;
}
.qde-math-container {
text-align: center;
margin: 16px 0;
overflow-x: auto;
}
/* All tables in preview (both regular markdown and CSV) */
.qde-preview table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 14px;
}
.qde-preview table th,
.qde-preview table td {
border: 1px solid #ddd;
padding: 8px;
}
/* Support for alignment classes from quikdown */
.qde-preview .quikdown-left { text-align: left; }
.qde-preview .quikdown-center { text-align: center; }
.qde-preview .quikdown-right { text-align: right; }
.qde-preview table th {
background: #f5f5f5;
font-weight: bold;
}
.qde-preview table tr:nth-child(even) {
background: #f9f9f9;
}
/* Specific to CSV-generated tables */
.qde-data-table {
/* Can add specific CSV table styles here if needed */
}
.qde-json {
/* Let highlight.js handle styling */
overflow-x: auto;
}
.qde-error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
padding: 8px;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
}
/* Read-only complex fence blocks in preview */
.qde-preview [contenteditable="false"] {
cursor: auto; /* Use automatic cursor (arrow for non-text) */
user-select: text;
position: relative;
}
/* Reset headings inside the preview to plain browser defaults so
parent-page styles (site navs, marketing pages, design systems)
cannot bleed in. Business-casual: black text, decreasing sizes,
no decorative borders. See docs/quikdown-editor.md for how
embedders can override these with their own stylesheet. */
.qde-preview h1 { font-size: 2em; }
.qde-preview h2 { font-size: 1.5em; }
.qde-preview h3 { font-size: 1.25em; }
.qde-preview h4 { font-size: 1em; }
.qde-preview h5 { font-size: 0.875em; }
.qde-preview h6 { font-size: 0.85em; }
.qde-preview h1,
.qde-preview h2,
.qde-preview h3,
.qde-preview h4,
.qde-preview h5,
.qde-preview h6 {
font-weight: bold;
color: inherit;
border: none;
margin: 0.6em 0 0.3em 0;
line-height: 1.25;
}
.qde-preview p {
margin: 0.35em 0;
}
.qde-preview ul,
.qde-preview ol {
padding-left: 1.8em;
margin: 0.4em 0;
}
.qde-preview li {
margin: 0.15em 0;
}
.qde-preview blockquote {
margin: 0.5em 0;
padding-left: 1em;
}
/* Ensure proper cursor for editable text elements */
.qde-preview p,
.qde-preview h1,
.qde-preview h2,
.qde-preview h3,
.qde-preview h4,
.qde-preview h5,
.qde-preview h6,
.qde-preview li,
.qde-preview td,
.qde-preview th,
.qde-preview blockquote,
.qde-preview pre[contenteditable="true"],
.qde-preview code[contenteditable="true"] {
cursor: text;
}
/* Non-editable complex renderers */
.qde-preview .qde-svg-container[contenteditable="false"],
.qde-preview .qde-html-container[contenteditable="false"],
.qde-preview .qde-math-container[contenteditable="false"],
.qde-preview .mermaid[contenteditable="false"] {
opacity: 0.98;
}
/* Subtle hover effect for read-only blocks */
.qde-preview [contenteditable="false"]:hover::after {
content: "Read-only";
position: absolute;
top: 2px;
right: 2px;
font-size: 10px;
color: #999;
background: rgba(255, 255, 255, 0.9);
padding: 2px 4px;
border-radius: 2px;
pointer-events: none;
}
/* Fix list padding in preview */
.qde-preview ul,
.qde-preview ol {
padding-left: 2em;
margin: 0.5em 0;
}
.qde-preview li {
margin: 0.25em 0;
}
/* Mode-specific visibility */
.qde-mode-source .qde-preview { display: none; }
.qde-mode-source .qde-source { border-right: none; }
.qde-mode-preview .qde-source { display: none; }
.qde-mode-split .qde-source,
.qde-mode-split .qde-preview { display: block; }
/* Dark theme */
.qde-dark {
background: #1e1e1e;
color: #e0e0e0;
border-color: #444;
}
.qde-dark .qde-toolbar {
background: #2d2d2d;
border-color: #444;
}
.qde-dark .qde-btn {
background: #3a3a3a;
color: #e0e0e0;
border-color: #555;
}
.qde-dark .qde-btn:hover {
background: #4a4a4a;
}
.qde-dark .qde-source {
border-color: #444;
}
.qde-dark .qde-textarea {
background: #1e1e1e;
color: #e0e0e0;
}
.qde-dark .qde-preview {
background: #1e1e1e;
color: #e0e0e0;
}
/* Dark mode code blocks */
.qde-dark .qde-preview pre {
background: #2d2d3a;
color: #e6e6f0;
}
.qde-dark .qde-preview code {
background: #2a2a3a;
color: #e6e6f0;
}
.qde-dark .qde-preview pre code {
background: transparent;
color: inherit;
}
/* Dark mode table styles */
.qde-dark .qde-preview table th,
.qde-dark .qde-preview table td {
border-color: #3a3a3a;
}
.qde-dark .qde-preview table th {
background: #2d2d2d;
}
.qde-dark .qde-preview table tr:nth-child(even) {
background: #252525;
}
/* Mobile split toggle — hidden by default */
.qde-split-toggle { display: none; }
/* Mobile responsive — compact toolbar for all small screens */
@media (max-width: 720px) {
.qde-toolbar {
padding: 6px;
gap: 3px;
}
.qde-btn {
padding: 5px 8px;
font-size: 12px;
}
.qde-source, .qde-preview {
padding: 10px;
}
.qde-textarea {
padding: 10px;
}
/* Undo/Redo: show circular arrows instead of text */
.qde-btn[data-action="undo"] { font-size: 0; }
.qde-btn[data-action="undo"]::after { content: "\\21B6"; font-size: 14px; }
.qde-btn[data-action="redo"] { font-size: 0; }
.qde-btn[data-action="redo"]::after { content: "\\21B7"; font-size: 14px; }
/* Hide secondary utility buttons to reduce clutter */
.qde-btn[data-action="remove-hr"],
.qde-btn[data-action="lazy-linefeeds"],
.qde-btn[data-action="copy-rendered"] { display: none; }
}
/* Portrait mobile: drop split mode entirely */
@media (max-width: 720px) and (orientation: portrait) {
.qde-btn[data-mode="split"] { display: none; }
.qde-split-toggle { display: none !important; }
/* Fallback: if still in split mode, show source only */
.qde-mode-split .qde-source { border-right: none; }
.qde-mode-split .qde-preview { display: none; }
.qde-mode-split.qde-split-preview .qde-source { display: none; }
.qde-mode-split.qde-split-preview .qde-preview { display: block; }
}
`;
document.head.appendChild(style);
}
/**
* Attach event listeners
*/
attachEvents() {
// Source textarea input
this.sourceTextarea.addEventListener('input', () => {
this.handleSourceInput();
});
// Preview contenteditable input
this.previewPanel.addEventListener('input', () => {
this.handlePreviewInput();
});
// Toolbar buttons
if (this.toolbar) {
this.toolbar.addEventListener('click', (e) => {
const btn = e.target.closest('.qde-btn');
if (!btn) return;
// Mobile split-toggle button
if (btn.classList.contains('qde-split-toggle')) {
this.container.classList.toggle('qde-split-preview');
const showingPreview = this.container.classList.contains('qde-split-preview');
btn.textContent = showingPreview ? 'Source' : 'Preview';
return;
}
if (btn.dataset.mode) {
this.setMode(btn.dataset.mode);
} else if (btn.dataset.action) {
this.handleAction(btn.dataset.action);
}
});
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case '1':
e.preventDefault();
this.setMode('source');
break;
case '2':
e.preventDefault();
this.setMode('split');
break;
case '3':
e.preventDefault();
this.setMode('preview');
break;
case 'z':
case 'Z':
if (e.shiftKey) {
e.preventDefault();
this.redo();
} else {
e.preventDefault();
this.undo();
}
break;
case 'y':
case 'Y':
e.preventDefault();
this.redo();
break;
}
}
});
// On narrow portrait viewports, auto-switch out of split mode to source.
// Split is kept available on landscape where there is enough width.
if (typeof window.matchMedia === 'function') {
const portraitQuery = window.matchMedia('(max-width: 720px) and (orientation: portrait)');
const switchIfPortrait = () => {
if (portraitQuery.matches && this.currentMode === 'split') {
this.setMode('source');
}
};
// Check after init's setMode() has run (microtask fires after sync code).
Promise.resolve().then(switchIfPortrait);
portraitQuery.addEventListener('change', switchIfPortrait);
}
}
/**
* Handle source textarea input
*/
handleSourceInput() {
clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
this.updateFromMarkdown(this.sourceTextarea.value);
}, this.options.debounceDelay);
}
/**
* Handle preview panel input
*/
handlePreviewInput() {
clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
this.updateFromHTML();
}, this.options.debounceDelay);
}
/**
* Update from markdown source
*/
updateFromMarkdown(markdown) {
// Push current state to undo stack before changing (unless this is an undo/redo operation)
if (!this._isUndoRedo) {
this._pushUndoState(markdown || '');
}
this._isUndoRedo = false;
this._markdown = markdown || '';
// Show placeholder if empty
if (!this._markdown.trim()) {
this._html = '';
if (this.currentMode !== 'source') {
this.previewPanel.innerHTML = '<div style="color: #999; font-style: italic; padding: 16px;">Start typing markdown in the source panel...</div>';
}
} else {
// Translate editor's allowUnsafeHTML to parser's allow_unsafe_html:
// false → false, true → true, 'limited' → quikdown_bd.SAFE_HTML_TAGS
const htmlMode = this.options.allowUnsafeHTML;
const allowHtml = htmlMode === 'limited' ? SAFE_HTML_TAGS : htmlMode;
this._html = quikdown_bd(markdown, {
fence_plugin: this.createFencePlugin(),
lazy_linefeeds: this.options.lazy_linefeeds,
inline_styles: this.options.inline_styles,
allow_unsafe_html: allowHtml,
reference_links: this.options.reference_links,
footnotes: this.options.footnotes
});
// Update preview if visible
if (this.currentMode !== 'source') {
this.previewPanel.innerHTML = this._html;
// Process all math elements with MathJax if loaded (like squibview)
this._typesetMath(this.previewPanel);
}
}
// Trigger change event
if (this.options.onChange) {
this.options.onChange(this._markdown, this._html);
}
}
/**
* Update from HTML preview
*/
updateFromHTML() {
// Clone the preview panel to avoid modifying the actual DOM
const clonedPanel = this.previewPanel.cloneNode(true);
// Pre-process special elements on the clone
this.preprocessSpecialElements(clonedPanel);
this._html = this.previewPanel.innerHTML;
const newMarkdown = quikdown_bd.toMarkdown(clonedPanel, {
fence_plugin: this.createFencePlugin()
});
// Push previous state to undo stack (now that we know the new markdown)
if (!this._isUndoRedo) {
this._pushUndoState(newMarkdown);
}
this._isUndoRedo = false;
this._markdown = newMarkdown;
// Update source if visible
if (this.currentMode !== 'preview') {
this.sourceTextarea.value = this._markdown;
}
// Trigger change event
if (this.options.onChange) {
this.options.onChange(this._markdown, this._html);
}
this._updateUndoButtons();
}
/**
* Pre-process special elements before markdown conversion
*/
preprocessSpecialElements(panel) {
if (!panel) return;
// Restore non-editable complex fences from their data attributes
const complexFences = panel.querySelectorAll('[contenteditable="false"][data-qd-source]');
complexFences.forEach(element => {
const source = element.getAttribute('data-qd-source');
const fence = element.getAttribute('data-qd-fence') || '```';
const lang = element.getAttribute('data-qd-lang') || '';
// Create a pre element with the original source
const pre = document.createElement('pre');
pre.setAttribute('data-qd-fence', fence);
if (lang) pre.setAttribute('data-qd-lang', lang);
const code = document.createElement('code');
// The source is already the original unescaped content when using setAttribute
// No need to unescape since browser handles it automatically
code.textContent = source;
pre.appendChild(code);
// Replace the complex element with pre
element.parentNode.replaceChild(pre, element);
});
// Convert CSV tables back to CSV fence blocks (these ARE editable)
const csvTables = panel.querySelectorAll('table.qde-csv-table[data-qd-lang]');
csvTables.forEach(table => {
const lang = table.getAttribute('data-qd-lang');
if (!lang || !['csv', 'psv', 'tsv'].includes(lang)) return;
const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
// Extract data from table
let csv = '';
// Get headers
const headers = [];
const headerCells = table.querySelectorAll('thead th');
headerCells.forEach(th => {
const text = th.textContent.trim();
// Quote if contains delimiter or quotes
const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
headers.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
});
csv += headers.join(delimiter) + '\n';
// Get rows
const rows = table.querySelectorAll('tbody tr');
rows.forEach(tr => {
const cells = [];
tr.querySelectorAll('td').forEach(td => {
const text = td.textContent.trim();
const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
cells.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
});
csv += cells.join(delimiter) + '\n';
});
// Create a pre element with the CSV data
const pre = document.createElement('pre');
pre.setAttribute('data-qd-fence', '```');
pre.setAttribute('data-qd-lang', lang);
const code = document.createElement('code');
code.textContent = csv.trim();
pre.appendChild(code);
// Replace table with pre
table.parentNode.replaceChild(pre, table);
});
}
/**
* Create fence plugin for syntax highlighting
*/
createFencePlugin() {
const render = (code, lang) => {
// Check custom fences first (they take precedence)
if (this.options.customFences && this.options.customFences[lang]) {
try {
return this.options.customFences[lang](code, lang);
} catch (err) {
console.error(`Custom fence plugin error for ${lang}:`, err);
return `<pre><code class="language-${this.escapeHtml(lang)}">${this.escapeHtml(code)}</code></pre>`;
}
}
// For bidirectional editing, only apply syntax highlighting
// Skip complex transformations that break round-trip conversion
const skipComplexRendering = !this.options.enableComplexFences;
if (!skipComplexRendering) {
// Built-in lazy loading fence handlers (disabled for now)
switch(lang) {
case 'svg':
return this.renderSVG(code);
case 'html':
return this.renderHTML(code);
case 'math':
case 'tex':
case 'latex':
return this.renderMath(code, lang);
case 'csv':
case 'psv':
case 'tsv':
return this.renderTable(code, lang);
case 'json':
case 'json5':
return this.renderJSON(code, lang);
case 'katex': // Use MathJax for katex fence blocks (backward compatibility)
return this.renderMath(code, 'katex');
case 'mermaid':
if (window.mermaid) {
return this.renderMermaid(code);
}
break;
case 'geojson':
return this.renderGeoJSON(code);
case 'stl':
return this.renderSTL(code);
case 'abc':
case 'music':
return this.renderABC(code);
case 'vega':
case 'vega-lite':
case 'vegalite':
return this.renderVega(code, lang);
}
}
// Syntax highlighting support - keep editable for bidirectional
if (window.hljs && lang && hljs.getLanguage(lang)) {
const highlighted = hljs.highlight(code, { language: lang }).value;
// Don't add contenteditable="false" - the bidirectional system can extract text from the highlighted code
return `<pre data-qd-fence="\`\`\`" data-qd-lang="${this.escapeHtml(lang)}"><code class="hljs language-${this.escapeHtml(lang)}">${highlighted}</code></pre>`;
}
// Default: let quikdown handle it
return undefined;
};
// Reverse function to extract raw source from rendered HTML
const reverse = (element) => {
// Get the language from data attribute
const lang = element.getAttribute('data-qd-lang') || '';
let content;
// For syntax-highlighted code, extract the raw text
if (element.querySelector('code.hljs')) {
const code = element.querySelector('code.hljs');
content = code.textContent || code.innerText || '';
}
// For other code blocks, just get the text content
else if (element.querySelector('code')) {
const codeEl = element.querySelector('code');
content = codeEl.textContent || codeEl.innerText || '';
}
// Fallback to element text
else {
content = element.textContent || element.innerText || '';
}
// Return in the format quikdown_bd expects
return {
content: content,
lang: lang,
fence: '```'
};
};
// Return object format for v1.1.0 API with both render and reverse
return { render, reverse };
}
/**
* Render SVG content
*/
renderSVG(code) {
try {
// Basic SVG validation
const parser = new DOMParser();
const doc = parser.parseFromString(code, 'image/svg+xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
throw new Error('Invalid SVG');
}
// Sanitize SVG by removing script tags and event handlers
const svg = doc.documentElement;
svg.querySelectorAll('script').forEach(el => el.remove());
// Remove event handlers
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
let node;
while ((node = walker.nextNode())) {
for (let i = node.attributes.length - 1; i >= 0; i--) {
const attr = node.attributes[i];
if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
node.removeAttribute(attr.name);
}
}
}
// Create container element programmatically to avoid attribute escaping issues
const container = document.createElement('div');
container.className = 'qde-svg-container';
container.contentEditable = 'false';
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', 'svg');
container.setAttribute('data-qd-source', code); // No escaping needed when using setAttribute!
container.innerHTML = new XMLSerializer().serializeToString(svg);
// Return the HTML string
return container.outerHTML;
} catch (err) {
const errorContainer = document.createElement('pre');
errorContainer.className = 'qde-error';
errorContainer.contentEditable = 'false';
errorContainer.setAttribute('data-qd-fence', '```');
errorContainer.setAttribute('data-qd-lang', 'svg');
errorContainer.textContent = `Invalid SVG: ${err.message}`;
return errorContainer.outerHTML;
}
}
/**
* Render HTML content with DOMPurify if available
*/
renderHTML(code) {
const id = `html-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// If DOMPurify is loaded, use it
if (window.DOMPurify) {
const clean = DOMPurify.sanitize(code);
// Create container programmatically
const container = document.createElement('div');
container.className = 'qde-html-container';
container.contentEditable = 'false';
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', 'html');
container.setAttribute('data-qd-source', code);
container.innerHTML = clean;
return container.outerHTML;
}
// Try to lazy load DOMPurify
this.lazyLoadLibrary(
'DOMPurify',
() => window.DOMPurify,
'https://unpkg.com/dompurify/dist/purify.min.js'
).then(loaded => {
if (loaded) {
const element = document.getElementById(id);
if (element) {
const clean = DOMPurify.sanitize(code);
element.innerHTML = clean;
// Update attributes after loading
element.setAttribute('data-qd-source', code);
element.setAttribute('data-qd-fence', '```');
element.setAttribute('data-qd-lang', 'html');
}
}
});
// Return placeholder with bidirectional attributes - non-editable
const placeholder = document.createElement('div');
placeholder.id = id;
placeholder.className = 'qde-html-container';
placeholder.contentEditable = 'false';
placeholder.setAttribute('data-qd-fence', '```');
placeholder.setAttribute('data-qd-lang', 'html');
placeholder.setAttribute('data-qd-source', code);
const pre = document.createElement('pre');
pre.textContent = code;
placeholder.appendChild(pre);
return placeholder.outerHTML;
}
/**
* Render math with MathJax (SVG output for better copy support)
*/
renderMath(code, _lang) {
const id = `math-${Math.random().toString(36).substring(2, 15)}`;
// Create container exactly like squibview
const container = document.createElement('div');
container.id = id;
container.className = 'math-display';
container.contentEditable = 'false';
container.setAttribute('data-source-type', 'math');
// Format content for MathJax (display mode with $$) - exactly like squibview
const singleLineContent = code.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim();
container.textContent = `$$${singleLineContent}$$`;
// Add centering style
container.style.textAlign = 'center';
container.style.margin = '1em 0';
// Ensure MathJax will be loaded (if not already)
if (!window.MathJax || !window.MathJax.typesetPromise) {
this.ensureMathJaxLoaded();
}
// MathJax will be processed in batch after preview update
return container.outerHTML;
}
/**
* Typeset math elements, handling both ready and not-yet-ready MathJax.
* In the standalone bundle MathJax may be imported but its async startup
* not yet complete, so typesetPromise is not available immediately.
*/
_typesetMath(root) {
const mathElements = (root || this.previewPanel || document).querySelectorAll('.math-display');
if (mathElements.length === 0) return;
const elements = Array.from(mathElements);
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise(elements).catch(() => {});
} else if (window.MathJax && window.MathJax.startup && window.MathJax.startup.promise) {
// Standalone bundle: MathJax imported but async startup not finished
window.MathJax.startup.promise.then(() => {
if (window.MathJax.typesetPromise) {
window.MathJax.typesetPromise(elements).catch(() => {});
}
}).catch(() => {});
} else if (window.MathJax) {
// MathJax config exists but startup not yet initialized — poll
const poll = (n) => {
if (n <= 0) return;
setTimeout(() => {
if (!window.MathJax) return;
if (window.MathJax.typesetPromise) {
window.MathJax.typesetPromise(elements).catch(() => {});
} else if (window.MathJax.startup && window.MathJax.startup.promise) {
window.MathJax.startup.promise.then(() => {
if (window.MathJax && window.MathJax.typesetPromise) {
window.MathJax.typesetPromise(elements).catch(() => {});
}
}).catch(() => {});
} else {
poll(n - 1);
}
}, 100);
};
poll(50);
}
}
/**
* Ensures MathJax is loaded (but doesn't process elements)
*/
ensureMathJaxLoaded() {
if (typeof window.MathJax === 'undefined' && !window.mathJaxLoading) {
window.mathJaxLoading = true;
// Configure MathJax before loading
if (!window.MathJax) {
window.MathJax = {
loader: { load: ['input/tex', 'output/svg'] },
tex: {
packages: { '[+]': ['ams'] },
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true,
processEnvironments: true
},
options: {
renderActions: { addMenu: [] },
ignoreHtmlClass: 'tex2jax_ignore',
processHtmlClass: 'tex2jax_process'
},
svg: {
fontCache: 'none' // Important: self-contained SVGs for copy
},
startup: { typeset: false }
};
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js';
script.async = true;
script.onload = () => {
window.mathJaxLoading = false;
// Process any existing math elements (like squibview)
this._typesetMath(this.previewPanel || document);
};
script.onerror = () => {
window.mathJaxLoading = false;
console.error('Failed to load MathJax');
};
document.head.appendChild(script);
}
}
/**
* Render CSV/PSV/TSV as HTML table
*/
renderTable(code, lang) {
const escapedCode = this.escapeHtml(code);
try {
const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
const lines = code.trim().split('\n');
if (lines.length === 0) {
return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
}
// CSV tables CAN be editable - we'll convert HTML table back to CSV
// Don't need data-qd-source since we convert the table structure back to CSV
let html = `<table class="qde-data-table qde-csv-table" data-qd-fence="\`\`\`" data-qd-lang="${lang}">`;
// Parse header
const header = this.parseCSVLine(lines[0], delimiter);
html += '<thead><tr>';
header.forEach(cell => {
html += `<th>${this.escapeHtml(cell.trim())}</th>`;
});
html += '</tr></thead>';
// Parse body
if (lines.length > 1) {
html += '<tbody>';
for (let i = 1; i < lines.length; i++) {
const row = this.parseCSVLine(lines[i], delimiter);
html += '<tr>';
row.forEach(cell => {
html += `<td>${this.escapeHtml(cell.trim())}</td>`;
});
html += '</tr>';
}
html += '</tbody>';
}
html += '</table>';
return html;
} catch (_err) {
return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
}
}
/**
* Parse CSV line handling quoted values
*/
parseCSVLine(line, delimiter) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"';
i++; // Skip next quote
} else {
inQuotes = !inQuotes;
}
} else if (char === delimiter && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
/**
* Render JSON with syntax highlighting
*/
renderJSON(code, lang) {
// If highlight.js is available, use it for all JSON
if (window.hljs && hljs.getLanguage('json')) {
try {
// Try to format if valid JSON
let toHighlight = code;
try {
const data = JSON.parse(code);
toHighlight = JSON.stringify(data, null, 2);
} catch (_e) {
// Use original if not valid JSON
}
const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
} catch (_e) {
// Fall through if highlighting fails
}
}
// No highlighting available - return plain
return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}">${this.escapeHtml(code)}</pre>`;
}
/**
* Render GeoJSON map
*/
renderGeoJSON(code) {
// Generate unique map ID (following SquibView pattern)
const mapId = `map-${Math.random().toString(36).substr(2, 15)}`;
// Function to render the map
const renderMap = () => {
const container = document.getElementById(mapId + '-container');
if (!container || !window.L) return;
try {
const data = JSON.parse(code);
// Clear container and set deterministic size for rasterization
const mapDiv = document.createElement('div');
mapDiv.id = mapId;
mapDiv.style.cssText = 'width: 100%; height: 300px;';
container.innerHTML = '';
container.appendChild(mapDiv);
// Create the map — SVG basemap (not canvas) to avoid tile-clipping bands at world zoom
const offlineVector = !this.options.allowExternalFetch;
const mapOptions = {
zoomAnimation: !offlineVector,
fadeAnimation: !offlineVector
};
if (offlineVector) {
mapOptions.minZoom = 2;
mapOptions.maxBounds = [[-84, -180], [84, 180]];
mapOptions.maxBoundsViscosity = 0.85;
}
const map = L.map(mapId, mapOptions);
if (offlineVector) {
map.getContainer().classList.add('qde-offline-map');
const tilePane = map.getPane('tilePane');
if (tilePane) tilePane.style.display = 'none';
}
// Store back-reference for capture (per Gem's guide)
container._map = map; // Avoid window pollution
// Add basemap — vector (offline) or OSM tiles (online)
let tileLayer = null;
const isDark = this.container.classList.contains('qde-dark');
if (!this.options.allowExternalFetch && window._qde_worldGeoJSON) {
const basemapStyle = (zoom) => ({
weight: zoom < 4 ? 0.45 : zoom < 6 ? 0.75 : (isDark ? 1 : 1.1),
color: isDark ? '#b8b8b8' : '#1a1a1a',
fillColor: isDark ? '#3a3a3e' : '#ebe6d9',
fillOpacity: 1
});
const admin1Style = (zoom) => ({
weight: zoom < 6 ? 0.45 : (isDark ? 0.5 : 0.6),
color: isDark ? '#666' : '#555',
fillOpacity: 0,
fill: false
});
// Country fills — SVG renderer (canvas tiles clip large polygons at low zoom)
const countriesLayer = L.geoJSON(window._qde_worldGeoJSON, {
smoothFactor: 1.5,
style: () => basemapStyle(map.getZoom()),
interactive: false
}).addTo(map);
// Admin-1 hidden when zoomed out (dense lines read as horizontal banding)
let admin1Layer = null;
if (window._qde_admin1GeoJSON) {
admin1Layer = L.geoJSON(window._qde_admin1GeoJSON, {
smoothFactor: 1,
style: () => admin1Style(map.getZoom()),
interactive: false
});
}
const syncBasemapForZoom = () => {
const z = map.getZoom();
countriesLayer.setStyle(basemapStyle(z));
if (admin1Layer) {
if (z >= 5) {
admin1Layer.setStyle(admin1Style(z));
if (!map.hasLayer(admin1Layer)) admin1Layer.addTo(map);
} else if (map.hasLayer(admin1Layer)) {
map.removeLayer(admin1Layer);
}
}
};
map.on('zoomend', syncBasemapForZoom);
syncBasemapForZoom();
} else {
// Standard OSM tiles
tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
crossOrigin: 'anonymous' // Important for canvas capture
});
tileLayer.addTo(map);
}
// Add GeoJSON layer
const geoJsonLayer = L.geoJSON(data);
geoJsonLayer.addTo(map);
// Fit bounds if valid
const bounds = geoJsonLayer.getBounds();
if (bounds.isValid()) {
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
if (sw.lat === ne.lat && sw.lng === ne.lng) {
// Single point — setView instead of fitBounds to avoid NaN
map.setView([sw.lat, sw.lng], 8);
} else {
map.fitBounds(bounds, { padding: [28, 28], maxZoom: 6 });
}
} else {
map.setView([0, 0], 2);
}
// Store references for copy-time capture
container._tileLayer = tileLayer;
container._geoJsonLayer = geoJsonLayer;
// Optional: Wait for tiles to load for better capture
if (tileLayer) {
tileLayer.on('load', () => {
container.setAttribute('data-tiles-loaded', 'true');
});
} else {
// Vector basemap — tiles always "loaded"
container.setAttribute('data-tiles-loaded', 'true');
}
} catch (err) {
container.innerHTML = `<pre class="qde-error">GeoJSON error: ${this.escapeHtml(err.message)}</pre>`;
}
};
// Check if Leaflet is already loaded
const runRender = () => {
const needBasemap = !this.options.allowExternalFetch
&& !window._qde_worldGeoJSON
&& typeof window._qde_ensureBasemap === 'function';
if (needBasemap) {
window._qde_ensureBasemap().then(() => renderMap()).catch(err => {
const el = document.getElementById(mapId + '-container');
if (el) {
el.innerHTML = `<pre class="qde-error">Basemap load failed: ${this.escapeHtml(err.message)}</pre>`;
}
});
} else {
renderMap();
}
};
if (window.L) {
// Render after DOM update
setTimeout(runRender, 0);
} else {
// Lazy load Leaflet only if not already loading
if (!window._qde_leaflet_loading) {
window._qde_leaflet_loading = this.lazyLoadLibrary(
'Leaflet',
() => window.L,
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
).catch(err => {
console.warn('Failed to load Leaflet:', err);
// Clear the loading promise so it can be retried
window._qde_leaflet_loading = null;
return false;
});
}
window._qde_leaflet_loading.then(loaded => {
if (loaded) {
runRender();
} else {
const element = document.getElementById(mapId + '-container');
if (element) {
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load map library</div>';
}
}
}).catch(() => {
// Error already handled above
});
}
// Return container following SquibView pattern
const container = document.createElement('div');
container.className = 'geojson-container';
container.id = mapId + '-container';
container.style.cssText = 'width: 100%; height: 300px; border: 1px solid #ddd; border-radius: 4px; margin: 0.5em 0; background: #f0f0f0;';
container.contentEditable = 'false';
// Preserve source for copy-time identification (per Gem's guide)
container.setAttribute('data-source-type', 'geojson');
container.setAttribute('data-original-source', this.escapeHtml(code));
// For bidirectional editing
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', 'geojson');
container.setAttribute('data-qd-source', code);
container.textContent = 'Loading map...';
return container.outerHTML;
}
/**
* Render STL 3D model
*/
renderSTL(code) {
const id = `qde-stl-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Function to render the 3D model (assumes window.THREE is loaded)
const render3D = () => {
const element = document.getElementById(id);
if (!element) return;
try {
const THREE = window.THREE;
// Create scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f0f0);
// Create camera
const camera = new THREE.PerspectiveCamera(75, element.clientWidth / 400, 0.1, 1000);
// Create renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(element.clientWidth, 400);
element.innerHTML = '';
element.appendChild(renderer.domElement);
// Store Three.js references for copy functionality (like squibview)
element._threeScene = scene;
element._threeCamera = camera;
element._threeRenderer = renderer;
// Parse STL data (ASCII format)
const geometry = this.parseSTL(code);
const material = new THREE.MeshLambertMaterial({ color: 0x0066ff });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Add lighting
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1).normalize();
scene.add(directionalLight);
// Position camera based on object bounds
const box = new THREE.Box3().setFromObject(mesh);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
camera.position.set(center.x + maxDim, center.y + maxDim, center.z + maxDim);
camera.lookAt(center);
// Animate
const animate = () => {
requestAnimationFrame(animate);
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
};
animate();
} catch (err) {
console.error('STL rendering error:', err);
element.innerHTML = `<pre class="qde-error">STL error: ${this.escapeHtml(err.message)}</pre>`;
}
};
// If Three.js is already loaded, render immediately. Otherwise lazy-load
// it from a CDN (matches the GeoJSON/Leaflet pattern).
if (window.THREE) {
setTimeout(render3D, 0);
} else {
if (!window._qde_three_loading) {
window._qde_three_loading = this.lazyLoadLibrary(
'Three.js',
() => window.THREE,
'https://unpkg.com/three@0.147.0/build/three.min.js'
).catch(_err => {
console.warn('Failed to load Three.js for STL rendering');
window._qde_three_loading = null;
return false;
});
}
window._qde_three_loading.then(loaded => {
if (loaded) {
render3D();
} else {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Three.js for STL rendering</div>';
}
}
});
}
// Return placeholder with data-stl-id for copy functionality
return `<div id="${id}" class="qde-stl-container" data-stl-id="${id}" data-qd-fence="\`\`\`" data-qd-lang="stl" data-qd-source="${this.escapeHtml(code)}" contenteditable="false" style="height: 400px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">Loading 3D model...</div>`;
}
/**
* Parse ASCII STL format
* @param {string} stlData - The STL file content
* @returns {THREE.BufferGeometry} - The parsed geometry
*/
parseSTL(stlData) {
const THREE = window.THREE;
const geometry = new THREE.BufferGeometry();
const vertices = [];
const normals = [];
const lines = stlData.split('\n');
let currentNormal = null;
for (let line of lines) {
line = line.trim();
if (line.startsWith('facet normal')) {
const parts = line.split(/\s+/);
currentNormal = [parseFloat(parts[2]), parseFloat(parts[3]), parseFloat(parts[4])];
} else if (line.startsWith('vertex')) {
const parts = line.split(/\s+/);
vertices.push(parseFloat(parts[1]), parseFloat(parts[2]), parseFloat(parts[3]));
if (currentNormal) {
normals.push(currentNormal[0], currentNormal[1], currentNormal[2]);
}
}
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
return geometry;
}
/**
* Render ABC music notation
*/
renderABC(code) {
const id = `qde-abc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const renderNotation = () => {
const element = document.getElementById(id);
if (!element || !window.ABCJS) return;
try {
element.innerHTML = '';
window.ABCJS.renderAbc(element, code, { responsive: 'resize' });
} catch (err) {
element.innerHTML = `<pre class="qde-error">ABC notation error: ${this.escapeHtml(err.message)}</pre>`;
}
};
if (window.ABCJS) {
setTimeout(renderNotation, 0);
} else {
if (!window._qde_abcjs_loading) {
window._qde_abcjs_loading = this.lazyLoadLibrary(
'ABCJS',
() => window.ABCJS,
'https://cdn.jsdelivr.net/npm/abcjs@6/dist/abcjs-basic-min.js'
).catch(err => {
console.warn('Failed to load ABCJS:', err);
window._qde_abcjs_loading = null;
return false;
});
}
window._qde_abcjs_loading.then(loaded => {
if (loaded) {
renderNotation();
} else {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load ABC notation library</div>';
}
}
});
}
const container = document.createElement('div');
container.id = id;
container.className = 'qde-abc-container';
container.contentEditable = 'false';
container.setAttribute('data-qd-source', code);
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', 'abc');
container.style.cssText = 'min-height: 80px; margin: 0.5em 0;';
container.textContent = 'Loading music notation...';
return container.outerHTML;
}
/**
* Render Vega or Vega-Lite visualization
*/
renderVega(code, lang) {
const id = `qde-vega-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const renderChart = () => {
const element = document.getElementById(id);
if (!element || !window.vegaEmbed) return;
try {
const spec = JSON.parse(code);
// If using vega-lite alias, ensure $schema is set for vega-lite
if ((lang === 'vega-lite' || lang === 'vegalite') && !spec.$schema) {
spec.$schema = 'https://vega.github.io/schema/vega-lite/v5.json';
}
// Warn about external URL data sources when allowExternalFetch is off
if (!this.options.allowExternalFetch) {
const specStr = JSON.stringify(spec);
if (/"url"\s*:/.test(specStr)) {
element.innerHTML = '<div style="padding: 12px; background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; color: #856404; font-size: 0.9em;">⚠ This Vega spec references external data URLs. External fetch is disabled in offline mode. Use inline <code>"values"</code> instead.</div>';
return;
}
}
window.vegaEmbed(element, spec, { renderer: 'svg', actions: false }).catch(err => {
element.innerHTML = `<pre class="qde-error">Vega error: ${this.escapeHtml(err.message)}</pre>`;
});
} catch (err) {
element.innerHTML = `<pre class="qde-error">Vega JSON error: ${this.escapeHtml(err.message)}</pre>`;
}
};
if (window.vegaEmbed) {
setTimeout(renderChart, 0);
} else {
if (!window._qde_vega_loading) {
// Vega-Embed requires vega + vega-lite loaded first (peer deps)
window._qde_vega_loading = (async () => {
try {
await this.loadScript('https://cdn.jsdelivr.net/npm/vega@5');
await this.loadScript('https://cdn.jsdelivr.net/npm/vega-lite@5');
await this.loadScript('https://cdn.jsdelivr.net/npm/vega-embed@6');
return !!window.vegaEmbed;
} catch (err) {
console.warn('Failed to load Vega:', err);
window._qde_vega_loading = null;
return false;
}
})();
}
window._qde_vega_loading.then(loaded => {
if (loaded) {
renderChart();
} else {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Failed to load Vega visualization library</div>';
}
}
});
}
const container = document.createElement('div');
container.id = id;
container.className = 'qde-vega-container';
container.contentEditable = 'false';
container.setAttribute('data-qd-source', code);
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', lang);
container.style.cssText = 'min-height: 100px; margin: 0.5em 0;';
container.textContent = 'Loading visualization...';
return container.outerHTML;
}
/**
* Render Mermaid diagram
*/
renderMermaid(code) {
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
setTimeout(() => {
const element = document.getElementById(id);
if (element && window.mermaid) {
mermaid.render(id + '-svg', code).then(result => {
element.innerHTML = result.svg;
}).catch(err => {
element.innerHTML = `<pre>Error rendering diagram: ${this.escapeHtml(err.message)}</pre>`;
});
}
}, 0);
// Create container programmatically
const container = document.createElement('div');
container.id = id;
container.className = 'mermaid';
container.contentEditable = 'false';
container.setAttribute('data-qd-source', code);
container.setAttribute('data-qd-fence', '```');
container.setAttribute('data-qd-lang', 'mermaid');
container.textContent = 'Loading diagram...';
return container.outerHTML;
}
/**
* Escape HTML for attributes
*/
escapeHtml(text) {
return (text ?? "").replace(/[&"'<>]/g, m =>
({'&':'&','"':'"',"'":''','<':'<','>':'>'}[m]));
}
/**
* Load plugins dynamically — honors both `plugins: { highlightjs, mermaid }`
* (legacy) and the newer `preloadFences` option which can preload any
* combination of fence libraries (or 'all') at construction time.
*/
async loadPlugins() {
const namesToLoad = new Set();
// Legacy plugins option
if (this.options.plugins) {
if (this.options.plugins.highlightjs) namesToLoad.add('highlightjs');
if (this.options.plugins.mermaid) namesToLoad.add('mermaid');
}
// New preloadFences option
const pf = this.options.preloadFences;
if (pf === 'all') {
Object.keys(this._fenceLibraries).forEach(n => namesToLoad.add(n));
} else if (Array.isArray(pf)) {
for (const entry of pf) {
if (typeof entry === 'string') {
if (this._fenceLibraries[entry]) namesToLoad.add(entry);
else console.warn(`QuikdownEditor: unknown preloadFences entry "${entry}"`);
} else if (entry && typeof entry === 'object' && entry.script) {
// Custom library: { name, script, css? }
namesToLoad.add('__custom__:' + (entry.name || entry.script));
this._fenceLibraries['__custom__:' + (entry.name || entry.script)] = {
check: () => false,
script: entry.script,
css: entry.css
};
}
}
} else if (pf) {
console.warn('QuikdownEditor: preloadFences should be "all", an array, or null');
}
// Load each in parallel; respect already-loaded state
const promises = [];
for (const name of namesToLoad) {
const lib = this._fenceLibraries[name];
if (!lib || lib.check()) continue;
if (lib.beforeLoad) lib.beforeLoad();
const p = (async () => {
try {
const tasks = [];
// scripts (array) = sequential deps; script (string) = single load
if (lib.scripts) {
for (const s of lib.scripts) await this.loadScript(s);
} else if (lib.script) {
tasks.push(this.loadScript(lib.script));
}
if (lib.css) tasks.push(this.loadCSS(lib.css, 'qde-hljs-light'));
if (lib.cssDark) tasks.push(this.loadCSS(lib.cssDark, 'qde-hljs-dark'));
await Promise.all(tasks);
if (lib.css && lib.cssDark) this._syncHljsTheme();
if (lib.afterLoad) lib.afterLoad();
} catch (err) {
console.warn(`QuikdownEditor: failed to preload ${name}:`, err);
}
})();
promises.push(p);
}
await Promise.all(promises);
}
/**
* Lazy load library if not already loaded
*/
async lazyLoadLibrary(name, check, scriptUrl, cssUrl = null) {
// Check if library is already loaded
if (check()) {
return true;
}
try {
const promises = [];
// Load script
if (scriptUrl) {
promises.push(this.loadScript(scriptUrl));
}
// Load CSS if provided
if (cssUrl) {
promises.push(this.loadCSS(cssUrl));
}
await Promise.all(promises);
// Verify library loaded
return check();
} catch (err) {
console.error(`Failed to load ${name}:`, err);
return false;
}
}
/**
* Load external script
*/
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* Load external CSS
*/
loadCSS(href, id) {
return new Promise((resolve) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
if (id) link.id = id;
link.onload = resolve;
document.head.appendChild(link);
// Resolve anyway after timeout (CSS doesn't always fire onload)
setTimeout(resolve, 1000);
});
}
/**
* Enable the hljs stylesheet matching the current theme and disable
* the other one. Called from applyTheme and after hljs CSS loads.
*/
_syncHljsTheme() {
const isDark = this.container.classList.contains('qde-dark');
const light = document.getElementById('qde-hljs-light');
const dark = document.getElementById('qde-hljs-dark');
if (light) light.disabled = isDark;
if (dark) dark.disabled = !isDark;
}
/**
* Apply the current theme (based on this.options.theme)
*/
applyTheme() {
const theme = this.options.theme;
// Tear down any previous auto-mode listener so we don't stack them
if (this._autoThemeListener) {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this._autoThemeListener);
this._autoThemeListener = null;
}
if (theme === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
this.container.classList.toggle('qde-dark', mq.matches);
this._autoThemeListener = (e) => {
this.container.classList.toggle('qde-dark', e.matches);
this._syncHljsTheme();
};
mq.addEventListener('change', this._autoThemeListener);
} else {
this.container.classList.toggle('qde-dark', theme === 'dark');
}
this._syncHljsTheme();
}
/**
* Set theme at runtime. Accepts 'light', 'dark', or 'auto'.
* @param {'light'|'dark'|'auto'} theme
*/
setTheme(theme) {
if (!['light', 'dark', 'auto'].includes(theme)) return;
this.options.theme = theme;
this.applyTheme();
}
/**
* Get the current theme option (as configured, not resolved).
* @returns {'light'|'dark'|'auto'}
*/
getTheme() {
return this.options.theme;
}
/**
* Set lazy linefeeds option
* @param {boolean} enabled - Whether to enable lazy linefeeds
*/
setLazyLinefeeds(enabled) {
this.options.lazy_linefeeds = enabled;
// Re-render if we have content
if (this._markdown) {
this.updateFromMarkdown(this._markdown);
}
}
/**
* Get lazy linefeeds option
* @returns {boolean}
*/
getLazyLinefeeds() {
return this.options.lazy_linefeeds;
}
/**
* Set debounce delay for input updates
* @param {number} delay - Delay in milliseconds (0 for instant)
*/
setDebounceDelay(delay) {
this.options.debounceDelay = Math.max(0, delay);
}
/**
* Get current debounce delay
* @returns {number} Delay in milliseconds
*/
getDebounceDelay() {
return this.options.debounceDelay;
}
/**
* Set editor mode
*/
setMode(mode) {
if (!['source', 'preview', 'split'].includes(mode)) return;
// Preserve theme class across mode swap (the assignment to className
// below would otherwise wipe it out — this used to be a no-op bug
// where dark mode was lost on every setMode call).
const wasDark = this.container.classList.contains('qde-dark');
const previousMode = this.currentMode;
this.currentMode = mode;
this.container.className = `qde-container qde-mode-${mode}`;
if (wasDark) {
this.container.classList.add('qde-dark');
}
// Reset mobile split-toggle button text
if (this.toolbar) {
const splitToggle = this.toolbar.querySelector('.qde-split-toggle');
if (splitToggle) {
splitToggle.textContent = 'Preview';
}
}
// Update toolbar buttons
if (this.toolbar) {
this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
}
// If the preview was hidden (source-only) it may have missed content
// updates. Re-render it now, including MathJax typesetting.
// Do NOT re-render if the preview was already visible — that would
// destroy MathJax-typeset SVG output with raw pre-typeset HTML.
if (mode !== 'source' && previousMode === 'source' && this._html) {
this.previewPanel.innerHTML = this._html;
this._typesetMath(this.previewPanel);
}
// Trigger mode change event
if (this.options.onModeChange) {
this.options.onModeChange(mode);
}
}
// --- Undo / Redo ---
/**
* Push current markdown state onto the undo stack (called before a change).
* Only pushes if the new state differs from the current state.
* @param {string} newMarkdown - the incoming markdown (used to detect no-op)
* @private
*/
_pushUndoState(newMarkdown) {
// Don't push if the content hasn't actually changed
if (newMarkdown === this._markdown) return;
this._undoStack.push(this._markdown);
// Enforce max stack size
const max = this.options.undoStackSize || 100;
if (this._undoStack.length > max) {
this._undoStack.splice(0, this._undoStack.length - max);
}
// Any new edit clears the redo stack
this._redoStack = [];
this._updateUndoButtons();
}
/**
* Undo the last change. Restores the previous markdown state.
*/
undo() {
if (!this.canUndo()) return;
// Save current state to redo stack
this._redoStack.push(this._markdown);
const previous = this._undoStack.pop();
this._isUndoRedo = true;
// Update state directly (setMarkdown is async; keep it synchronous here)
this._markdown = previous;
if (this.sourceTextarea) {
this.sourceTextarea.value = previous;
}
this.updateFromMarkdown(previous);
this._updateUndoButtons();
}
/**
* Redo the last undone change.
*/
redo() {
if (!this.canRedo()) return;
// Save current state to undo stack
this._undoStack.push(this._markdown);
const next = this._redoStack.pop();
this._isUndoRedo = true;
this._markdown = next;
if (this.sourceTextarea) {
this.sourceTextarea.value = next;
}
this.updateFromMarkdown(next);
this._updateUndoButtons();
}
/**
* @returns {boolean} true if undo is possible
*/
canUndo() {
return this._undoStack.length > 0;
}
/**
* @returns {boolean} true if redo is possible
*/
canRedo() {
return this._redoStack.length > 0;
}
/**
* Clear the undo and redo history.
*/
clearHistory() {
this._undoStack = [];
this._redoStack = [];
this._updateUndoButtons();
}
/**
* Update the disabled state of the undo/redo toolbar buttons.
* @private
*/
_updateUndoButtons() {
if (!this.toolbar) return;
const undoBtn = this.toolbar.querySelector('[data-action="undo"]');
const redoBtn = this.toolbar.querySelector('[data-action="redo"]');
if (undoBtn) {
undoBtn.classList.toggle('disabled', !this.canUndo());
}
if (redoBtn) {
redoBtn.classList.toggle('disabled', !this.canRedo());
}
}
/**
* Handle toolbar actions
*/
handleAction(action) {
switch(action) {
case 'copy-markdown':
this.copy('markdown');
break;
case 'copy-html':
this.copy('html');
break;
case 'copy-rendered':
this.copyRendered();
break;
case 'remove-hr':
this.removeHR();
break;
case 'lazy-linefeeds':
this.convertLazyLinefeeds();
break;
case 'undo':
this.undo();
break;
case 'redo':
this.redo();
break;
case 'toggle-html-mode':
this.cycleAllowUnsafeHTML();
break;
}
}
/**
* Copy content to clipboard
*/
async copy(type) {
const content = type === 'markdown' ? this._markdown : this._html;
try {
await navigator.clipboard.writeText(content);
// Visual feedback
const btn = this.toolbar.querySelector(`[data-action="copy-${type}"]`);
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => {
btn.textContent = originalText;
}, 1500);
}
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Public API
/**
* Get current markdown
*/
get markdown() {
return this._markdown;
}
/**
* Set markdown content
*/
set markdown(value) {
this.setMarkdown(value);
}
/**
* Get current HTML
*/
get html() {
return this._html;
}
/**
* Get current mode
*/
get mode() {
return this.currentMode;
}
/**
* Set markdown content
*/
async setMarkdown(markdown) {
// Wait for initialization if needed
if (this.initPromise) {
await this.initPromise;
}
this._markdown = markdown;
if (this.sourceTextarea) {
this.sourceTextarea.value = markdown;
}
this.updateFromMarkdown(markdown);
}
/**
* Get markdown content
*/
getMarkdown() {
return this._markdown;
}
/**
* Get HTML content
*/
getHTML() {
return this._html;
}
/**
* Remove all horizontal rules (---) from markdown source.
* Preserves content inside fences (``` or ~~~) and table separator rows.
*/
async removeHR() {
const cleaned = QuikdownEditor.removeHRFromMarkdown(this._markdown);
await this.setMarkdown(cleaned);
// Visual feedback if toolbar button exists
const btn = this.toolbar?.querySelector('[data-action="remove-hr"]');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Removed!';
setTimeout(() => {
btn.textContent = originalText;
}, 1500);
}
}
/**
* Static: remove horizontal rules from markdown string.
* Safe for fences, tables, and all markdown constructs.
* Can be used headless without an editor instance.
* @param {string} markdown - source markdown
* @returns {string} markdown with standalone HRs removed
*/
static removeHRFromMarkdown(markdown) {
const lines = (markdown || '').split('\n');
const result = [];
let inFence = false;
let openChar = null;
let openLen = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Track fence open/close
if (!inFence) {
const fo = fenceOpen(trimmed);
if (fo) {
inFence = true;
openChar = fo.char;
openLen = fo.len;
result.push(line);
continue;
}
} else {
if (isFenceClose(trimmed, openChar, openLen)) {
inFence = false;
openChar = null;
openLen = 0;
}
result.push(line);
continue;
}
// Detect table row/separator with pipes — always keep
if (/^\|.*\|$/.test(trimmed) || (/^[-| :]+$/.test(trimmed) && trimmed.includes('|'))) {
result.push(line);
continue;
}
// Check if this line is a standalone HR (no ReDoS — linear scan)
if (isHRLine(trimmed)) {
// Table separator heuristic: immediately adjacent lines (no blank
// lines between) that look like table rows protect this HR-like line
const prevLine = i > 0 ? lines[i - 1].trim() : '';
const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
if (looksLikeTableRow(prevLine) || looksLikeTableRow(nextLine)) {
result.push(line);
continue;
}
// It's a real HR — skip it
continue;
}
result.push(line);
}
return result.join('\n');
}
/**
* Convert lazy linefeeds in markdown source.
* Replaces single newlines with double newlines (adds real line breaks)
* except inside fences, tables, and other block-level constructs.
* Idempotent: calling multiple times produces the same result.
* Can be used as a toolbar action or headless via the static method.
*/
async convertLazyLinefeeds() {
const converted = QuikdownEditor.convertLazyLinefeeds(this._markdown);
await this.setMarkdown(converted);
// Visual feedback if toolbar button exists
const btn = this.toolbar?.querySelector('[data-action="lazy-linefeeds"]');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Converted!';
setTimeout(() => {
btn.textContent = originalText;
}, 1500);
}
}
/**
* Static: convert lazy linefeeds in markdown source.
* Turns single \n between non-blank lines into \n\n so each line becomes
* its own paragraph / hard break. Idempotent — already-doubled newlines
* are not doubled again. Fences, tables, lists, blockquotes, headings,
* and HTML blocks are left untouched.
* @param {string} markdown - source markdown
* @returns {string} markdown with lazy linefeeds resolved
*/
static convertLazyLinefeeds(markdown) {
// Two-phase approach (much cleaner than the old single pass):
//
// Phase A: walk lines, classify each as { content, blank, fence }.
// Inside fences, lines are passed through verbatim.
// Phase B: emit lines with the rule:
// "between two adjacent CONTENT lines, ensure exactly one
// blank line — never zero, never more than one."
//
// The rule applies regardless of whether the content lines are
// headings, lists, blockquotes, table rows, paragraphs, or HR — any
// adjacent pair of non-fence non-blank lines gets exactly one blank
// between them. This produces the cleanest possible output for any
// input and is fully idempotent.
//
// Lines that are whitespace-only (e.g. " ") are normalized to
// empty strings, eliminating "phantom" blank lines.
//
// Lists are a special case: adjacent list items (same marker type)
// should NOT get a blank line between them, otherwise we'd break
// tight lists.
//
// Same applies to blockquote lines and table rows — adjacent rows
// belong to the same block.
const inputLines = (markdown || '').split('\n');
// -------- Phase A: classify lines, normalize whitespace-only --------
// Each entry: { line, kind } where kind is one of:
// 'fence-open', 'fence-close', 'fence-body', 'blank', 'content'
// Plus a 'category' for content lines: 'list-ul', 'list-ol',
// 'blockquote', 'table', 'heading', 'hr', 'paragraph'
const items = [];
let inFence = false;
let openChar = null;
let openLen = 0;
for (const rawLine of inputLines) {
const line = rawLine;
const trimmed = line.trim();
// Fence tracking via shared utilities
if (!inFence) {
const fo = fenceOpen(trimmed);
if (fo) {
inFence = true;
openChar = fo.char;
openLen = fo.len;
items.push({ line, kind: 'fence-open' });
continue;
}
} else {
if (isFenceClose(trimmed, openChar, openLen)) {
inFence = false;
openChar = null;
openLen = 0;
items.push({ line, kind: 'fence-close' });
} else {
items.push({ line, kind: 'fence-body' });
}
continue;
}
// Outside fence: whitespace-only lines become canonical blanks
if (trimmed === '') {
items.push({ line: '', kind: 'blank' });
continue;
}
// Categorize content lines (no ReDoS — classifyLine uses linear scan for HR)
let category = classifyLine(trimmed);
// Indented continuation of a list (2+ leading spaces or tab)
if (category === 'paragraph' && /^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) {
category = 'list-cont';
}
items.push({ line, kind: 'content', category });
}
// -------- Phase B: emit with exactly-one-blank-line normalization --------
// Same-block adjacent lines (lists, blockquotes, tables) stay
// touching; any other adjacent content pair gets exactly one blank.
const result = [];
let prev = null; // last emitted non-blank content item
function inSameBlock(a, b) {
if (!a || !b) return false;
// Lists: same marker family OR list-content continuation
if ((a.category === 'list-ul' || a.category === 'list-ol' || a.category === 'list-cont') &&
(b.category === 'list-ul' || b.category === 'list-ol' || b.category === 'list-cont')) {
return true;
}
// Blockquotes
if (a.category === 'blockquote' && b.category === 'blockquote') return true;
// Table rows
if (a.category === 'table' && b.category === 'table') return true;
return false;
}
for (const item of items) {
if (item.kind === 'fence-open' || item.kind === 'fence-body' || item.kind === 'fence-close') {
// Fences: ensure exactly one blank line before the fence-open
if (item.kind === 'fence-open' && prev && result.length > 0 && result[result.length - 1] !== '') {
result.push('');
}
result.push(item.line);
if (item.kind === 'fence-close') prev = { kind: 'content', category: 'fence' };
continue;
}
if (item.kind === 'blank') {
// Skip — Phase B inserts its own blank lines as needed
continue;
}
// item.kind === 'content'
if (prev) {
if (inSameBlock(prev, item)) ; else {
// Different blocks (or paragraphs): exactly one blank
if (result[result.length - 1] !== '') result.push('');
}
}
result.push(item.line);
prev = item;
}
// Trim trailing blank lines so output has exactly one terminal newline
while (result.length > 0 && result[result.length - 1] === '') result.pop();
return result.join('\n');
}
/**
* Copy rendered content as rich text
* @param {string} [output='default'] - Output profile: 'default', 'quikdown', or 'stripped'
*/
async copyRendered(output = 'default') {
try {
const result = await getRenderedContent(this.previewPanel, { output });
if (result.success) {
// Visual feedback
const btn = this.toolbar?.querySelector('[data-action="copy-rendered"]');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => {
btn.textContent = originalText;
}, 1500);
}
}
} catch (err) {
console.error('Failed to copy rendered content:', err);
}
}
/**
* Get the label for the current HTML passthrough mode.
* @private
*/
_getHtmlModeLabel(mode) {
if (mode === true) return 'HTML: Raw';
if (mode === 'limited') return 'HTML: Safe';
return 'HTML: Off';
}
/** @private */
_getHtmlModeTooltip(mode) {
if (mode === true) return 'All HTML passes through — no protection';
if (mode === 'limited') return 'Safe tags render, dangerous tags escaped';
return 'All HTML tags shown as text';
}
/**
* Cycle allowUnsafeHTML through false → "limited" → true → false.
*/
cycleAllowUnsafeHTML() {
const current = this.options.allowUnsafeHTML;
let next;
if (current === false) next = 'limited';
else if (current === 'limited') next = true;
else next = false;
this.setAllowUnsafeHTML(next);
}
/**
* Set the HTML passthrough mode.
* @param {boolean|'limited'} mode - false, 'limited', or true
*/
setAllowUnsafeHTML(mode) {
if (mode !== false && mode !== true && mode !== 'limited') return;
this.options.allowUnsafeHTML = mode;
// Update toolbar button label
if (this.toolbar) {
const btn = this.toolbar.querySelector('[data-action="toggle-html-mode"]');
if (btn) {
btn.textContent = this._getHtmlModeLabel(mode);
btn.title = this._getHtmlModeTooltip(mode);
}
}
// Re-render
this.updateFromMarkdown(this._markdown);
}
/**
* Get the current HTML passthrough mode.
* @returns {boolean|'limited'}
*/
getAllowUnsafeHTML() {
return this.options.allowUnsafeHTML;
}
/**
* Destroy the editor
*/
destroy() {
// Clear timers
clearTimeout(this.updateTimer);
// Clear container
this.container.innerHTML = '';
this.container.classList.remove('qde-container', 'qde-dark');
// Remove injected styles (only if no other editors exist)
const otherEditors = document.querySelectorAll('.qde-container');
if (otherEditors.length === 0) {
const style = document.getElementById('qde-styles');
if (style) style.remove();
}
}
}
/** Static: curated safe HTML tag whitelist for allow_unsafe_html */
QuikdownEditor.SAFE_HTML_TAGS = SAFE_HTML_TAGS;
/** Semantic version (injected at build time) */
QuikdownEditor.version = quikdownEditorVersion;
// Export for CommonJS (needed for bundled ESM to work with Jest)
if (typeof module !== 'undefined' && module.exports) {
module.exports = QuikdownEditor;
}
// Also export for UMD builds
if (typeof window !== 'undefined') {
window.QuikdownEditor = QuikdownEditor;
}
module.exports = QuikdownEditor;