diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 3781355..4c29c8f 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -9,7 +9,7 @@ document.addEventListener("DOMContentLoaded", function () { // View Mode State - Story 1.1 let currentViewMode = 'split'; // 'editor', 'split', or 'preview' - const APP_VERSION = '3.5.3'; + const APP_VERSION = '3.5.4'; let activeModal = null; let lastFocusedElement = null; let isFindModalOpen = false; @@ -21,6 +21,7 @@ document.addEventListener("DOMContentLoaded", function () { const markdownPreview = document.getElementById("markdown-preview"); const markdownFormatToolbar = document.getElementById("markdown-format-toolbar"); const themeToggle = document.getElementById("theme-toggle"); + const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); const importFromGithubButton = document.getElementById("import-from-github"); const fileInput = document.getElementById("file-input"); @@ -66,6 +67,7 @@ document.addEventListener("DOMContentLoaded", function () { const mobileExportPdf = document.getElementById("mobile-export-pdf"); const mobileCopyMarkdown = document.getElementById("mobile-copy-markdown"); const mobileThemeToggle = document.getElementById("mobile-theme-toggle"); + const mobileDirectionToggle = document.getElementById("mobile-direction-toggle"); const shareButton = document.getElementById("share-button"); const mobileShareButton = document.getElementById("mobile-share-button"); const githubImportModal = document.getElementById("github-import-modal"); @@ -209,6 +211,32 @@ document.addEventListener("DOMContentLoaded", function () { ? '' : ''; + function updateDirectionToggleUI(direction) { + const isRtl = direction === "rtl"; + const toggleLabel = isRtl ? "Switch to LTR" : "Switch to RTL"; + if (directionToggle) { + directionToggle.textContent = isRtl ? "R" : "L"; + directionToggle.setAttribute("title", toggleLabel); + directionToggle.setAttribute("aria-label", toggleLabel); + directionToggle.setAttribute("aria-pressed", isRtl.toString()); + } + if (mobileDirectionToggle) { + const icon = isRtl + ? '' + : ''; + mobileDirectionToggle.innerHTML = `${icon} ${toggleLabel}`; + } + } + + const savedDirection = loadGlobalState().direction; + const initialDirection = savedDirection === "rtl" ? "rtl" : "ltr"; + function applyDirectionToContent(direction) { + if (markdownEditor) markdownEditor.setAttribute("dir", direction); + if (markdownPreview) markdownPreview.setAttribute("dir", direction); + } + applyDirectionToContent(initialDirection); + updateDirectionToggleUI(initialDirection); + const initMermaid = () => { const currentTheme = document.documentElement.getAttribute("data-theme"); const mermaidTheme = currentTheme === "dark" ? "dark" : "default"; @@ -247,6 +275,150 @@ document.addEventListener("DOMContentLoaded", function () { const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; + const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; + const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; + const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; + const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; + const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; + const EMPTY_LINE_PATTERN = /^\s*$/; + const footnoteDefinitions = new Map(); + const footnoteOrder = []; + const footnoteRefCounts = new Map(); + const footnoteFirstRefId = new Map(); + let anonymousFootnoteCounter = 0; + + function resetExtendedMarkdownState() { + footnoteDefinitions.clear(); + footnoteOrder.length = 0; + footnoteRefCounts.clear(); + footnoteFirstRefId.clear(); + anonymousFootnoteCounter = 0; + } + + function normalizeFootnoteId(id) { + const normalized = String(id || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (normalized) { + return normalized; + } + + anonymousFootnoteCounter += 1; + return `footnote-${anonymousFootnoteCounter}`; + } + + function escapeHtmlAttribute(value) { + return String(value) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); + } + + function renderDefinitionContent(content) { + return content + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph) => { + const renderedParagraph = marked.parseInline(paragraph); + const safeParagraph = typeof DOMPurify !== "undefined" + ? DOMPurify.sanitize(renderedParagraph) + : renderedParagraph; + return `

${safeParagraph}

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

      ${footnotesHtml}
    `; + } + const blockMathExtension = { name: 'blockMath', level: 'block', @@ -272,6 +444,163 @@ document.addEventListener("DOMContentLoaded", function () { return `
    $$\n${token.text}\n$$
    \n`; } }; + const definitionListExtension = { + name: "definitionList", + level: "block", + start(src) { + const match = src.match(/\n:[ \t]+/); + if (!match) { + return undefined; + } + return match.index + 1; + }, + tokenizer(src) { + const lines = src.split("\n"); + if (lines.length < 2) { + return undefined; + } + + const term = lines[0]; + if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) { + return undefined; + } + + if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) { + return undefined; + } + + const definitions = []; + const rawLines = [term]; + let index = 1; + while (index < lines.length) { + const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); + if (!itemMatch) { + break; + } + + rawLines.push(lines[index]); + const definitionLines = [itemMatch[1]]; + index += 1; + + while (index < lines.length) { + const line = lines[index]; + if (DEFINITION_LIST_ITEM_PATTERN.test(line)) { + break; + } + if (EMPTY_LINE_PATTERN.test(line)) { + const nextLine = lines[index + 1] || ""; + if (/^(?: {2,}|\t)/.test(nextLine)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + break; + } + const continuationMatch = /^(?: {2,}|\t)(.*)$/.exec(line); + if (!continuationMatch) { + break; + } + + rawLines.push(line); + definitionLines.push(continuationMatch[1]); + index += 1; + } + + definitions.push(definitionLines.join("\n").trim()); + } + + if (definitions.length === 0) { + return undefined; + } + + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) { + raw += "\n"; + } + + return { + type: "definitionList", + raw: raw, + term: term.trim(), + definitions: definitions, + }; + }, + renderer(token) { + const termHtml = marked.parseInline(token.term); + const definitionHtml = token.definitions + .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) + .join(""); + return `
    ${termHtml}
    ${definitionHtml}
    \n`; + }, + }; + const superscriptExtension = { + name: "superscript", + level: "inline", + start(src) { + const index = src.indexOf("^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUPERSCRIPT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "superscript", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + const subscriptExtension = { + name: "subscript", + level: "inline", + start(src) { + const index = src.indexOf("~"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUBSCRIPT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "subscript", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + const highlightExtension = { + name: "highlight", + level: "inline", + start(src) { + const index = src.indexOf("=="); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = HIGHLIGHT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "highlight", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + renderer.code = function (code, language) { if (language === 'mermaid') { const uniqueId = 'mermaid-diagram-' + Math.random().toString(36).substr(2, 9); @@ -286,7 +615,19 @@ document.addEventListener("DOMContentLoaded", function () { }; marked.use({ - extensions: [blockMathExtension] + extensions: [ + blockMathExtension, + definitionListExtension, + superscriptExtension, + subscriptExtension, + highlightExtension, + ], + hooks: { + preprocess(markdown) { + resetExtendedMarkdownState(); + return applyFootnotes(extractFootnoteDefinitions(markdown)); + }, + }, }); marked.setOptions({ @@ -1106,8 +1447,8 @@ This is a fully client-side application. Your content never leaves your browser const referenceData = extractReferenceDefinitions(body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style', 'align'], + ADD_TAGS: ['mjx-container', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled'], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); markdownPreview.innerHTML = sanitizedHtml; @@ -3588,6 +3929,19 @@ This is a fully client-side application. Your content never leaves your browser mobileExportHtml.addEventListener("click", () => exportHtml.click()); mobileExportPdf.addEventListener("click", () => exportPdf.click()); mobileCopyMarkdown.addEventListener("click", () => copyMarkdownButton.click()); + if (mobileDirectionToggle) { + mobileDirectionToggle.addEventListener("click", () => { + if (directionToggle) { + directionToggle.click(); + } else { + const currentDir = markdownEditor ? markdownEditor.getAttribute("dir") : "ltr"; + const direction = currentDir === "rtl" ? "ltr" : "rtl"; + applyDirectionToContent(direction); + saveGlobalState({ direction }); + updateDirectionToggleUI(direction); + } + }); + } mobileThemeToggle.addEventListener("click", () => { themeToggle.click(); mobileThemeToggle.innerHTML = themeToggle.innerHTML + " Toggle Dark Mode"; @@ -3693,6 +4047,15 @@ This is a fully client-side application. Your content never leaves your browser }); previewPane.addEventListener("scroll", syncPreviewToEditor); toggleSyncButton.addEventListener("click", toggleSyncScrolling); + if (directionToggle) { + directionToggle.addEventListener("click", function () { + const currentDir = markdownEditor ? markdownEditor.getAttribute("dir") : "ltr"; + const direction = currentDir === "rtl" ? "ltr" : "rtl"; + applyDirectionToContent(direction); + saveGlobalState({ direction }); + updateDirectionToggleUI(direction); + }); + } themeToggle.addEventListener("click", function () { const theme = document.documentElement.getAttribute("data-theme") === "dark" @@ -3777,8 +4140,8 @@ This is a fully client-side application. Your content never leaves your browser const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style', 'align'] + ADD_TAGS: ['mjx-container', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled'] }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -4395,8 +4758,8 @@ This is a fully client-side application. Your content never leaves your browser const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start'] + ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled'] }); const tempElement = document.createElement("div"); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index efa662b..ade2bde 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -192,6 +192,65 @@ body { vertical-align: -0.1em; } +.markdown-body ul.contains-task-list, +.markdown-body li.task-list-item { + list-style: none; +} + +.markdown-body ul.contains-task-list { + padding-left: 0; +} + +.markdown-body li.task-list-item input[type="checkbox"] { + margin: 0 0.5em 0.2em 0; + vertical-align: middle; + pointer-events: none; +} + +.markdown-body li.task-list-item::marker { + content: ""; +} + +.markdown-body li:has(> input[type="checkbox"]) { + list-style: none; +} + +.markdown-body li:has(> input[type="checkbox"])::marker { + content: ""; +} + +.markdown-body ul:has(> li > input[type="checkbox"]) { + list-style: none; + padding-left: 0; +} + +.markdown-body .footnotes { + margin-top: 1.5rem; + font-size: 0.9em; +} + +.markdown-body .footnotes ol { + padding-left: 1.5em; +} + +.markdown-body .footnotes ol > li::marker { + content: "[" counter(list-item) "] "; + font-weight: 600; +} + +.markdown-body .footnotes li > p { + margin: 0.2em 0; +} + +.markdown-body .footnote-ref a, +.markdown-body .footnote-backref { + text-decoration: none; +} + +.markdown-body .footnote-backref { + margin-left: 0.4em; +} + .markdown-body .markdown-alert { padding: 0.5rem 1rem; margin-bottom: 16px; @@ -2547,3 +2606,69 @@ a:focus { background-color: var(--button-bg); white-space: nowrap; } + +/* ======================================== + RTL SUPPORT + ======================================== */ + +[dir="rtl"] body { + direction: rtl; +} + +[dir="rtl"] #markdown-editor, +[dir="rtl"] .markdown-body { + direction: rtl; + text-align: right; +} + +[dir="rtl"] .markdown-body pre, +[dir="rtl"] .markdown-body code, +[dir="rtl"] .fm-complex { + direction: ltr; + text-align: left; +} + +[dir="rtl"] .line-numbers { + left: auto; + right: 20px; + padding: 10px 0 10px 8px; + text-align: left; + border-right: none; + border-left: 1px solid var(--border-color); +} + +[dir="rtl"] #markdown-editor { + padding-left: 10px; + padding-right: calc(10px + var(--line-number-gutter)); +} + +[dir="rtl"] .editor-highlight-layer { + inset: 20px calc(20px + var(--line-number-gutter)) 20px 0; +} + +[dir="rtl"] .mobile-menu-item, +[dir="rtl"] .tab-menu-item, +[dir="rtl"] .modal-header .reset-modal-message, +[dir="rtl"] .reset-modal-field, +[dir="rtl"] .alert-option, +[dir="rtl"] .github-import-error, +[dir="rtl"] #github-import-modal .reset-modal-message, +[dir="rtl"] .github-tree-file-btn, +[dir="rtl"] .frontmatter-table td { + text-align: right; +} + +[dir="rtl"] .github-import-tree ul { + padding-left: 0; + padding-right: 18px; +} + +[dir="rtl"] .github-import-tree > ul { + padding-right: 4px; +} + +[dir="rtl"] .markdown-body .markdown-alert, +[dir="rtl"] .alert-preview .markdown-alert { + border-left: 0; + border-right: 0.25em solid currentColor; +} diff --git a/script.js b/script.js index e3f22be..4c29c8f 100644 --- a/script.js +++ b/script.js @@ -275,6 +275,150 @@ document.addEventListener("DOMContentLoaded", function () { const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; + const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; + const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; + const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; + const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; + const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; + const EMPTY_LINE_PATTERN = /^\s*$/; + const footnoteDefinitions = new Map(); + const footnoteOrder = []; + const footnoteRefCounts = new Map(); + const footnoteFirstRefId = new Map(); + let anonymousFootnoteCounter = 0; + + function resetExtendedMarkdownState() { + footnoteDefinitions.clear(); + footnoteOrder.length = 0; + footnoteRefCounts.clear(); + footnoteFirstRefId.clear(); + anonymousFootnoteCounter = 0; + } + + function normalizeFootnoteId(id) { + const normalized = String(id || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (normalized) { + return normalized; + } + + anonymousFootnoteCounter += 1; + return `footnote-${anonymousFootnoteCounter}`; + } + + function escapeHtmlAttribute(value) { + return String(value) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); + } + + function renderDefinitionContent(content) { + return content + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map((paragraph) => { + const renderedParagraph = marked.parseInline(paragraph); + const safeParagraph = typeof DOMPurify !== "undefined" + ? DOMPurify.sanitize(renderedParagraph) + : renderedParagraph; + return `

    ${safeParagraph}

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

      ${footnotesHtml}
    `; + } + const blockMathExtension = { name: 'blockMath', level: 'block', @@ -300,6 +444,163 @@ document.addEventListener("DOMContentLoaded", function () { return `
    $$\n${token.text}\n$$
    \n`; } }; + const definitionListExtension = { + name: "definitionList", + level: "block", + start(src) { + const match = src.match(/\n:[ \t]+/); + if (!match) { + return undefined; + } + return match.index + 1; + }, + tokenizer(src) { + const lines = src.split("\n"); + if (lines.length < 2) { + return undefined; + } + + const term = lines[0]; + if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) { + return undefined; + } + + if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) { + return undefined; + } + + const definitions = []; + const rawLines = [term]; + let index = 1; + while (index < lines.length) { + const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); + if (!itemMatch) { + break; + } + + rawLines.push(lines[index]); + const definitionLines = [itemMatch[1]]; + index += 1; + + while (index < lines.length) { + const line = lines[index]; + if (DEFINITION_LIST_ITEM_PATTERN.test(line)) { + break; + } + if (EMPTY_LINE_PATTERN.test(line)) { + const nextLine = lines[index + 1] || ""; + if (/^(?: {2,}|\t)/.test(nextLine)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + break; + } + const continuationMatch = /^(?: {2,}|\t)(.*)$/.exec(line); + if (!continuationMatch) { + break; + } + + rawLines.push(line); + definitionLines.push(continuationMatch[1]); + index += 1; + } + + definitions.push(definitionLines.join("\n").trim()); + } + + if (definitions.length === 0) { + return undefined; + } + + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) { + raw += "\n"; + } + + return { + type: "definitionList", + raw: raw, + term: term.trim(), + definitions: definitions, + }; + }, + renderer(token) { + const termHtml = marked.parseInline(token.term); + const definitionHtml = token.definitions + .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) + .join(""); + return `
    ${termHtml}
    ${definitionHtml}
    \n`; + }, + }; + const superscriptExtension = { + name: "superscript", + level: "inline", + start(src) { + const index = src.indexOf("^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUPERSCRIPT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "superscript", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + const subscriptExtension = { + name: "subscript", + level: "inline", + start(src) { + const index = src.indexOf("~"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUBSCRIPT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "subscript", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + const highlightExtension = { + name: "highlight", + level: "inline", + start(src) { + const index = src.indexOf("=="); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = HIGHLIGHT_PATTERN.exec(src); + if (!match) { + return undefined; + } + return { + type: "highlight", + raw: match[0], + text: match[1], + }; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + renderer.code = function (code, language) { if (language === 'mermaid') { const uniqueId = 'mermaid-diagram-' + Math.random().toString(36).substr(2, 9); @@ -314,7 +615,19 @@ document.addEventListener("DOMContentLoaded", function () { }; marked.use({ - extensions: [blockMathExtension] + extensions: [ + blockMathExtension, + definitionListExtension, + superscriptExtension, + subscriptExtension, + highlightExtension, + ], + hooks: { + preprocess(markdown) { + resetExtendedMarkdownState(); + return applyFootnotes(extractFootnoteDefinitions(markdown)); + }, + }, }); marked.setOptions({ @@ -1134,8 +1447,8 @@ This is a fully client-side application. Your content never leaves your browser const referenceData = extractReferenceDefinitions(body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style', 'align'], + ADD_TAGS: ['mjx-container', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled'], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); markdownPreview.innerHTML = sanitizedHtml; @@ -3827,8 +4140,8 @@ This is a fully client-side application. Your content never leaves your browser const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container'], - ADD_ATTR: ['id', 'class', 'style', 'align'] + ADD_TAGS: ['mjx-container', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled'] }); const tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; @@ -4445,8 +4758,8 @@ This is a fully client-side application. Your content never leaves your browser const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { - ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start'] + ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled'] }); const tempElement = document.createElement("div"); diff --git a/styles.css b/styles.css index ce26589..ade2bde 100644 --- a/styles.css +++ b/styles.css @@ -192,6 +192,65 @@ body { vertical-align: -0.1em; } +.markdown-body ul.contains-task-list, +.markdown-body li.task-list-item { + list-style: none; +} + +.markdown-body ul.contains-task-list { + padding-left: 0; +} + +.markdown-body li.task-list-item input[type="checkbox"] { + margin: 0 0.5em 0.2em 0; + vertical-align: middle; + pointer-events: none; +} + +.markdown-body li.task-list-item::marker { + content: ""; +} + +.markdown-body li:has(> input[type="checkbox"]) { + list-style: none; +} + +.markdown-body li:has(> input[type="checkbox"])::marker { + content: ""; +} + +.markdown-body ul:has(> li > input[type="checkbox"]) { + list-style: none; + padding-left: 0; +} + +.markdown-body .footnotes { + margin-top: 1.5rem; + font-size: 0.9em; +} + +.markdown-body .footnotes ol { + padding-left: 1.5em; +} + +.markdown-body .footnotes ol > li::marker { + content: "[" counter(list-item) "] "; + font-weight: 600; +} + +.markdown-body .footnotes li > p { + margin: 0.2em 0; +} + +.markdown-body .footnote-ref a, +.markdown-body .footnote-backref { + text-decoration: none; +} + +.markdown-body .footnote-backref { + margin-left: 0.4em; +} + .markdown-body .markdown-alert { padding: 0.5rem 1rem; margin-bottom: 16px;