Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Markdown Toggle (Source View)
3 *
4 * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes.
5 * Pure state management — no DOM manipulation. The caller (main.js) handles
6 * the actual UI show/hide of editor vs textarea.
7 *
8 * Custom TipTap blocks (mermaid, math, page breaks, toggle blocks, footnotes,
9 * suggestion marks) cannot survive a markdown roundtrip, so they are extracted
10 * before conversion and restored afterward using placeholder markers.
11 */
12import type { MarkdownToggleOptions, MarkdownToggleApi } from './types.js';
13
14export const TOGGLE_MODE = Object.freeze({
15 WYSIWYG: 'wysiwyg' as const,
16 MARKDOWN: 'markdown' as const,
17});
18
19export type ToggleMode = typeof TOGGLE_MODE[keyof typeof TOGGLE_MODE];
20
21// --- Custom block preservation ---
22
23/** Regex patterns for custom TipTap blocks that don't survive markdown roundtrip */
24const CUSTOM_BLOCK_PATTERNS = [
25 // Mermaid blocks
26 /<div[^>]*data-mermaid-block[^>]*>[\s\S]*?<\/div>/gi,
27 // Math blocks
28 /<div[^>]*data-math-block[^>]*>[\s\S]*?<\/div>/gi,
29 // Page breaks
30 /<div[^>]*data-type="page-break"[^>]*>[\s\S]*?<\/div>/gi,
31 // Toggle blocks (details/summary)
32 /<details[^>]*>[\s\S]*?<\/details>/gi,
33 // Footnote markers (inline)
34 /<sup[^>]*data-footnote-id[^>]*>[^<]*<\/sup>/gi,
35 // Suggestion insert marks
36 /<span[^>]*data-suggestion-type="insert"[^>]*>[\s\S]*?<\/span>/gi,
37 /<span[^>]*class="suggestion-insert"[^>]*>[\s\S]*?<\/span>/gi,
38 // Suggestion delete marks
39 /<span[^>]*data-suggestion-type="delete"[^>]*>[\s\S]*?<\/span>/gi,
40 /<span[^>]*class="suggestion-delete"[^>]*>[\s\S]*?<\/span>/gi,
41];
42
43interface PreservedBlock {
44 placeholder: string;
45 html: string;
46}
47
48/**
49 * Extract custom blocks from HTML, replacing them with unique placeholders.
50 */
51export function extractCustomBlocks(html: string): { cleanHtml: string; blocks: PreservedBlock[] } {
52 const blocks: PreservedBlock[] = [];
53 let cleanHtml = html;
54
55 for (const pattern of CUSTOM_BLOCK_PATTERNS) {
56 cleanHtml = cleanHtml.replace(pattern, (match) => {
57 const placeholder = `\u00AB\u00ABBLOCK_${blocks.length}\u00BB\u00BB`;
58 blocks.push({ placeholder, html: match });
59 return placeholder;
60 });
61 }
62
63 return { cleanHtml, blocks };
64}
65
66/**
67 * Restore custom blocks from markdown text by replacing placeholders with original HTML.
68 */
69export function restoreCustomBlocks(html: string, blocks: PreservedBlock[]): string {
70 let result = html;
71 for (const block of blocks) {
72 // The placeholder may be wrapped in <p> tags by markdown-it
73 const wrappedPattern = new RegExp(
74 `<p>${escapeRegex(block.placeholder)}<\\/p>`,
75 'g'
76 );
77 result = result.replace(wrappedPattern, block.html);
78 // Also replace bare placeholders (not wrapped)
79 result = result.replace(block.placeholder, block.html);
80 }
81 return result;
82}
83
84function escapeRegex(s: string): string {
85 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86}
87
88/**
89 * Create a markdown toggle state manager.
90 */
91export function createMarkdownToggle(opts: MarkdownToggleOptions): MarkdownToggleApi {
92 const { getEditorHtml, setEditorHtml, htmlToMarkdown, markdownToHtml, onModeChange } = opts;
93
94 let mode: ToggleMode = TOGGLE_MODE.WYSIWYG;
95 let markdownContent = '';
96 let preservedBlocks: PreservedBlock[] = [];
97
98 function toggle(): void {
99 if (mode === TOGGLE_MODE.WYSIWYG) {
100 // Switching TO markdown mode: extract custom blocks, then convert
101 const editorHtml = getEditorHtml();
102 const { cleanHtml, blocks } = extractCustomBlocks(editorHtml);
103 preservedBlocks = blocks;
104 markdownContent = htmlToMarkdown(cleanHtml);
105 // Append preserved block placeholders as visible comments in markdown
106 // so the user can see them and knows not to delete them
107 for (const block of blocks) {
108 markdownContent = markdownContent.replace(
109 block.placeholder,
110 `<!-- preserved: ${block.placeholder} -->`
111 );
112 }
113 mode = TOGGLE_MODE.MARKDOWN;
114 } else {
115 // Switching BACK to WYSIWYG: restore placeholders, convert markdown, re-inject blocks
116 let md = markdownContent;
117 // Restore placeholder syntax from HTML comments
118 for (const block of preservedBlocks) {
119 md = md.replace(
120 `<!-- preserved: ${block.placeholder} -->`,
121 block.placeholder
122 );
123 }
124 let html = markdownToHtml(md);
125 html = restoreCustomBlocks(html, preservedBlocks);
126 setEditorHtml(html);
127 markdownContent = '';
128 preservedBlocks = [];
129 mode = TOGGLE_MODE.WYSIWYG;
130 }
131
132 if (onModeChange) {
133 onModeChange(mode);
134 }
135 }
136
137 function getMode(): string {
138 return mode;
139 }
140
141 function isMarkdownMode(): boolean {
142 return mode === TOGGLE_MODE.MARKDOWN;
143 }
144
145 function getMarkdownContent(): string {
146 return markdownContent;
147 }
148
149 function setMarkdownContent(content: string): void {
150 if (mode === TOGGLE_MODE.MARKDOWN) {
151 markdownContent = content;
152 }
153 // Ignored in WYSIWYG mode
154 }
155
156 return {
157 toggle,
158 getMode,
159 isMarkdownMode,
160 getMarkdownContent,
161 setMarkdownContent,
162 };
163}