/** * Markdown Toggle (Source View) * * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes. * Pure state management — no DOM manipulation. The caller (main.js) handles * the actual UI show/hide of editor vs textarea. * * Custom TipTap blocks (mermaid, math, page breaks, toggle blocks, footnotes, * suggestion marks) cannot survive a markdown roundtrip, so they are extracted * before conversion and restored afterward using placeholder markers. */ import type { MarkdownToggleOptions, MarkdownToggleApi } from './types.js'; export const TOGGLE_MODE = Object.freeze({ WYSIWYG: 'wysiwyg' as const, MARKDOWN: 'markdown' as const, }); export type ToggleMode = typeof TOGGLE_MODE[keyof typeof TOGGLE_MODE]; // --- Custom block preservation --- /** Regex patterns for custom TipTap blocks that don't survive markdown roundtrip */ const CUSTOM_BLOCK_PATTERNS = [ // Mermaid blocks /]*data-mermaid-block[^>]*>[\s\S]*?<\/div>/gi, // Math blocks /]*data-math-block[^>]*>[\s\S]*?<\/div>/gi, // Page breaks /]*data-type="page-break"[^>]*>[\s\S]*?<\/div>/gi, // Toggle blocks (details/summary) /]*>[\s\S]*?<\/details>/gi, // Footnote markers (inline) /]*data-footnote-id[^>]*>[^<]*<\/sup>/gi, // Suggestion insert marks /]*data-suggestion-type="insert"[^>]*>[\s\S]*?<\/span>/gi, /]*class="suggestion-insert"[^>]*>[\s\S]*?<\/span>/gi, // Suggestion delete marks /]*data-suggestion-type="delete"[^>]*>[\s\S]*?<\/span>/gi, /]*class="suggestion-delete"[^>]*>[\s\S]*?<\/span>/gi, ]; interface PreservedBlock { placeholder: string; html: string; } /** * Extract custom blocks from HTML, replacing them with unique placeholders. */ export function extractCustomBlocks(html: string): { cleanHtml: string; blocks: PreservedBlock[] } { const blocks: PreservedBlock[] = []; let cleanHtml = html; for (const pattern of CUSTOM_BLOCK_PATTERNS) { cleanHtml = cleanHtml.replace(pattern, (match) => { const placeholder = `\u00AB\u00ABBLOCK_${blocks.length}\u00BB\u00BB`; blocks.push({ placeholder, html: match }); return placeholder; }); } return { cleanHtml, blocks }; } /** * Restore custom blocks from markdown text by replacing placeholders with original HTML. */ export function restoreCustomBlocks(html: string, blocks: PreservedBlock[]): string { let result = html; for (const block of blocks) { // The placeholder may be wrapped in

tags by markdown-it const wrappedPattern = new RegExp( `

${escapeRegex(block.placeholder)}<\\/p>`, 'g' ); result = result.replace(wrappedPattern, block.html); // Also replace bare placeholders (not wrapped) result = result.replace(block.placeholder, block.html); } return result; } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Create a markdown toggle state manager. */ export function createMarkdownToggle(opts: MarkdownToggleOptions): MarkdownToggleApi { const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts; let mode: ToggleMode = TOGGLE_MODE.WYSIWYG; let markdownContent = ''; let preservedBlocks: PreservedBlock[] = []; function toggle(): void { if (mode === TOGGLE_MODE.WYSIWYG) { // Switching TO markdown mode: extract custom blocks, then convert const editorHtml = getEditorHtml(); const { cleanHtml, blocks } = extractCustomBlocks(editorHtml); preservedBlocks = blocks; markdownContent = htmlToMarkdown(cleanHtml); // Append preserved block placeholders as visible comments in markdown // so the user can see them and knows not to delete them for (const block of blocks) { markdownContent = markdownContent.replace( block.placeholder, `` ); } mode = TOGGLE_MODE.MARKDOWN; } else { // Switching BACK to WYSIWYG: restore placeholders, convert markdown, re-inject blocks let md = markdownContent; // Restore placeholder syntax from HTML comments for (const block of preservedBlocks) { md = md.replace( ``, block.placeholder ); } let html = markdownToHtml(md); html = restoreCustomBlocks(html, preservedBlocks); setEditorHtml(html); markdownContent = ''; preservedBlocks = []; mode = TOGGLE_MODE.WYSIWYG; } if (onModeChange) { onModeChange(mode); } } function getMode(): string { return mode; } function isMarkdownMode(): boolean { return mode === TOGGLE_MODE.MARKDOWN; } function getMarkdownContent(): string { return markdownContent; } function setMarkdownContent(content: string): void { if (mode === TOGGLE_MODE.MARKDOWN) { markdownContent = content; } // Ignored in WYSIWYG mode } return { toggle, getMode, isMarkdownMode, getMarkdownContent, setMarkdownContent, }; }