diff --git a/pics/2.6.2/callouts.png b/pics/2.6.2/callouts.png new file mode 100644 index 0000000..635a069 Binary files /dev/null and b/pics/2.6.2/callouts.png differ diff --git a/pics/2.6.2/start-in-editor.png b/pics/2.6.2/start-in-editor.png new file mode 100644 index 0000000..0e82f99 Binary files /dev/null and b/pics/2.6.2/start-in-editor.png differ diff --git a/samples/test-features.md b/samples/test-features.md index 730fadb..793bc8e 100644 --- a/samples/test-features.md +++ b/samples/test-features.md @@ -16,9 +16,24 @@ Jumps to location in document --- +# Callouts + +> [!note] Custom titled callout +> This is a note callout + +> [!tip] You can combine ==highlights== with **bold** for emphasis. + +> [!IMPORTANT] Nested callouts work too +> > [!note] Nested callout +> > This is a nested callout +> > > [!info] Nested callout +> > > This is a nested callout + +--- + # Highlights -You can ==highlight text== inline just like in Obsidian. +You can ==highlight text== inline. Multiple ==highlights== can appear ==in the same== paragraph. @@ -50,7 +65,7 @@ Long note name with alias — this demonstrates that the block ID is separate fr A third paragraph with its own anchor you can reference from the TOC. ^third-anchor -Obsidian-style internal links: [[#important]] jumps to the first paragraph above. +Internal links: [[#important]] jumps to the first paragraph above. --- @@ -103,25 +118,6 @@ And level 5. --- -## GFM Alerts - -> [!NOTE] -> This is a note alert — check if the icon and color are correct. - -> [!TIP] -> Tip: combine ==highlights== with**bold** for emphasis. - -> [!IMPORTANT] -> Block IDs ^block-in-quote are supported inside blockquotes too. - -> [!WARNING] -> Task toggles auto-save to disk — make sure you have write access. - -> [!CAUTION] -> Unchecking a task rewrites the raw markdown file immediately. - ---- - ## Code Blocks Inline code: `let x = 42;` — `==no highlight here==` @@ -160,7 +156,7 @@ fn process_highlights(content: &str) -> String { | Standard footnotes | `[^ref]` | ✅ | | Block IDs | `^id` | ✅ | | Task toggle | `- [ ]` click | ✅ | -| Obsidian links | `[[#heading]]` | ✅ | +| Wikilinks | `[[#heading]]` | ✅ | --- @@ -176,7 +172,8 @@ Superscript: x^2 (not supported inline) vs actual footnote^[this is a footnote]. A regular link: [GitHub](https://github.com) -An image: ![Test](https://via.placeholder.com/400x200?text=Test+Image) +An image: +![Test](https://picsum.photos/400/200) --- @@ -227,6 +224,3 @@ graph TD B -->|No| D[Debug it] D --> B ``` - ---- - diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6255c5d..7ebf570 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,8 +18,7 @@ async fn show_window(window: tauri::Window) { window.show().unwrap(); } -fn process_obsidian_embeds(content: &str) -> Cow<'_, str> { - // Match code blocks, inline code, or obsidian embeds +fn process_internal_embeds(content: &str) -> Cow<'_, str> { let re = Regex::new(r"(?s)```.*?```|`.*?`|!\[\[(.*?)\]\]").unwrap(); re.replace_all(content, |caps: &Captures| { @@ -56,7 +55,7 @@ fn process_obsidian_embeds(content: &str) -> Cow<'_, str> { }) } -fn process_obsidian_links<'a>(content: &'a str) -> Cow<'a, str> { +fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { let mut processed = Cow::Borrowed(content); // 1. Process [[#target]] or [[#target|alias]] @@ -135,8 +134,8 @@ fn process_obsidian_links<'a>(content: &'a str) -> Cow<'a, str> { #[tauri::command] fn convert_markdown(content: &str) -> String { - let processed_embeds = process_obsidian_embeds(content); - let processed_links = process_obsidian_links(&processed_embeds); + let processed_embeds = process_internal_embeds(content); + let processed_links = process_wikilinks(&processed_embeds); let mut options = ComrakOptions { extension: ComrakExtensionOptions { @@ -180,6 +179,11 @@ fn save_file_content(path: String, content: String) -> Result<(), String> { fs::write(path, content).map_err(|e| e.to_string()) } +#[tauri::command] +fn save_file_binary(path: String, data: Vec) -> Result<(), String> { + fs::write(path, data).map_err(|e| e.to_string()) +} + #[tauri::command] fn open_file_folder(path: String) -> Result<(), String> { opener::reveal(path).map_err(|e| e.to_string()) @@ -577,6 +581,11 @@ fn delete_file(path: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn copy_file(src: String, dest: String) -> Result<(), String> { + fs::copy(src, dest).map(|_| ()).map_err(|e| e.to_string()) +} + #[tauri::command] fn cleanup_empty_img_dir(parent_dir: String) -> Result<(), String> { let img_dir = Path::new(&parent_dir).join("img"); @@ -764,7 +773,7 @@ pub fn run() { if is_installer_mode { let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { width: 450.0, - height: 550.0, + height: 650.0, })); let _ = window.center(); } @@ -780,6 +789,7 @@ pub fn run() { send_markdown_path, read_file_content, save_file_content, + save_file_binary, get_app_mode, setup::install_app, setup::uninstall_app, @@ -800,6 +810,7 @@ pub fn run() { save_image, copy_file_to_img, delete_file, + copy_file, cleanup_empty_img_dir, list_directory_contents ]) diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 11c93e2..f41c472 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -16,6 +16,9 @@ import Toc from './components/Toc.svelte'; import { slide } from 'svelte/transition'; import Toast from './components/Toast.svelte'; + import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export'; + import ZoomOverlay from './components/ZoomOverlay.svelte'; +import { processMarkdownHtml } from './utils/markdown'; const appWindow = getCurrentWindow(); @@ -81,6 +84,8 @@ // in-page scroll position history for mouse 4/5 nav let scrollHistory: number[] = []; let scrollFuture: number[] = []; + let collapsedHeaders = $state(new Set()); + let zoomData = $state<{ src?: string; html?: string } | null>(null); // derived from tab manager let activeTab = $derived(tabManager.activeTab); @@ -90,6 +95,7 @@ // derived from tab manager let currentFile = $derived(tabManager.activeTab?.path ?? ''); + let isMarkdown = $derived(['md', 'markdown', 'mdown', 'mkd'].includes(currentFile.split('.').pop()?.toLowerCase() || '')); let editorLanguage = $derived(getLanguage(currentFile)); let htmlContent = $derived(tabManager.activeTab?.content ?? ''); let sanitizedHtml = $derived(DOMPurify.sanitize(htmlContent)); @@ -100,6 +106,9 @@ let showHome = $state(false); let isFullWidth = $state(localStorage.getItem('isFullWidth') === 'true'); + let viewerWidth = $state(0); + const TOC_WIDTH = 240; + let isOverhanging = $derived(isFullWidth || (viewerWidth > 0 && TOC_WIDTH > Math.max(50, (viewerWidth - 780) / 2))); $effect(() => { localStorage.setItem('isFullWidth', String(isFullWidth)); @@ -152,7 +161,7 @@ }); // ui state - let tooltip = $state({ show: false, text: '', html: '', isFootnote: false, x: 0, y: 0 }); + let tooltip = $state({ show: false, text: '', html: '', isFootnote: false, x: 0, y: 0, align: 'top' as 'top' | 'right' }); let caretEl: HTMLElement; let caretAbsoluteTop = 0; let modalState = $state<{ @@ -272,107 +281,6 @@ showHome = false; }); - function processMarkdownHtml(html: string, filePath: string): string { - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - - // resolve relative image paths - for (const img of doc.querySelectorAll('img')) { - const src = img.getAttribute('src'); - let finalSrc = src; - if (src && !src.startsWith('http') && !src.startsWith('data:')) { - try { - const decodedSrc = decodeURIComponent(src); - finalSrc = convertFileSrc(resolvePath(filePath, decodedSrc)); - img.setAttribute('src', finalSrc); - } catch (e) { - console.error('Failed to decode/resolve image src:', src, e); - } - } - - if (src) { - const ext = src.split('.').pop()?.toLowerCase(); - const isVideo = ['mp4', 'webm', 'ogg', 'mov'].includes(ext || ''); - const isAudio = ['mp3', 'wav', 'aac', 'flac', 'm4a'].includes(ext || ''); - - if (isVideo || isAudio) { - const media = doc.createElement(isVideo ? 'video' : 'audio'); - media.setAttribute('controls', ''); - media.setAttribute('src', finalSrc || ''); - media.style.maxWidth = '100%'; - - // Copy attributes - if (img.hasAttribute('width')) media.setAttribute('width', img.getAttribute('width')!); - if (img.hasAttribute('height')) media.setAttribute('height', img.getAttribute('height')!); - if (img.hasAttribute('alt')) media.setAttribute('aria-label', img.getAttribute('alt')!); - if (img.hasAttribute('title')) media.setAttribute('title', img.getAttribute('title')!); - - img.replaceWith(media); - continue; - } - - if (isYoutubeLink(src)) { - const videoId = getYoutubeId(src); - if (videoId) replaceWithYoutubeEmbed(img, videoId); - } - } - } - - // convert youtube links to embeds - for (const a of doc.querySelectorAll('a')) { - const href = a.getAttribute('href'); - if (href && isYoutubeLink(href)) { - const parent = a.parentElement; - if (parent && (parent.tagName === 'P' || parent.tagName === 'DIV') && parent.childNodes.length === 1) { - const videoId = getYoutubeId(href); - if (videoId) replaceWithYoutubeEmbed(a, videoId); - } - } - } - - // parse gfm alerts - for (const bq of doc.querySelectorAll('blockquote')) { - const firstP = bq.querySelector('p'); - if (firstP) { - const text = firstP.textContent || ''; - const match = text.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i); - if (match) { - const alertIcons: Record = { - note: '', - tip: '', - important: - '', - warning: - '', - caution: - '', - }; - - const type = match[1].toLowerCase(); - const alertDiv = doc.createElement('div'); - alertDiv.className = `markdown-alert markdown-alert-${type}`; - - const titleP = doc.createElement('p'); - titleP.className = 'markdown-alert-title'; - titleP.innerHTML = `${alertIcons[type] || ''} ${type.charAt(0).toUpperCase() + type.slice(1)}`; - - alertDiv.appendChild(titleP); - - firstP.textContent = text.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i, '').trim() || ''; - if (firstP.textContent === '' && firstP.nextSibling) firstP.remove(); - - while (bq.firstChild) alertDiv.appendChild(bq.firstChild); - bq.replaceWith(alertDiv); - } - } - } - - processBlockIds(doc.body, doc); - processTaskItems(doc.body); - processInlineMath(doc.body); - - return doc.body.innerHTML; - } function processInlineMath(root: Element) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { @@ -537,9 +445,9 @@ const tab = tabManager.tabs.find((t) => t.id === activeId); if (isMarkdown) { - if (tab) tab.isEditing = false; + if (tab) tab.isEditing = settings.startInEditor; const html = (await invoke('open_markdown', { path: filePath })) as string; - const processedInfo = processMarkdownHtml(html, filePath); + const processedInfo = processMarkdownHtml(html, filePath, collapsedHeaders); tabManager.updateTabContent(activeId, processedInfo); } else { if (tab) tab.isEditing = true; @@ -888,9 +796,79 @@ syncEditorToPreviewScroll(target); } + function toggleFold(key: string) { + const isCurrentlyCollapsed = collapsedHeaders.has(key); + + if (isCurrentlyCollapsed) { + const next = new Set(collapsedHeaders); + next.delete(key); + collapsedHeaders = next; + } else { + collapsedHeaders = new Set([...collapsedHeaders, key]); + } + + if (!markdownBody) return; + + let h = markdownBody.querySelector(`[id="${CSS.escape(key)}"].foldable-header`) as HTMLElement | null; + if (!h) { + const allHeaders = markdownBody.querySelectorAll('.foldable-header'); + for (const el of Array.from(allHeaders)) { + if ((el.textContent?.trim() || '') === key) { + h = el as HTMLElement; + break; + } + } + } + if (!h) return; + + const wrapId = h.getAttribute('data-fold-target'); + const wrapper = wrapId ? document.getElementById(wrapId) : null; + if (!wrapper) return; + + h.classList.toggle('is-collapsed', !isCurrentlyCollapsed); + wrapper.classList.toggle('is-collapsed', !isCurrentlyCollapsed); + } + function handleLinkClick(e: MouseEvent) { const target = e.target as HTMLElement; + // header fold toggle + const foldIcon = target.closest('.header-fold-icon'); + const foldableHeader = foldIcon ? foldIcon.closest('.foldable-header') as HTMLElement : null; + if (foldableHeader) { + if (e.detail > 1) e.preventDefault(); // prevent double-click selection + e.stopPropagation(); + const key = foldableHeader.id || foldableHeader.textContent?.trim() || ''; + const wrapId = foldableHeader.getAttribute('data-fold-target'); + const wrapper = wrapId ? document.getElementById(wrapId) : null; + if (wrapper) { + const isCollapsed = foldableHeader.classList.toggle('is-collapsed'); + wrapper.classList.toggle('is-collapsed', isCollapsed); + if (isCollapsed) { + collapsedHeaders = new Set([...collapsedHeaders, key]); + } else { + const next = new Set(collapsedHeaders); + next.delete(key); + collapsedHeaders = next; + } + } + return; + } + + // callout fold toggle + const calloutToggle = target.closest('.callout-toggle'); + if (calloutToggle) { + if (e.detail > 1) e.preventDefault(); // prevent double-click selection + e.stopPropagation(); + const alert = calloutToggle.closest('.callout-foldable'); + const content = alert?.querySelector('.markdown-alert-content'); + if (alert && content) { + alert.classList.toggle('is-collapsed'); + content.classList.toggle('is-collapsed'); + } + return; + } + // task checkbox toggle in read mode if (target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'checkbox' && target.hasAttribute('data-task-checkbox')) { e.preventDefault(); @@ -920,7 +898,23 @@ } } } - } + + // media zoom handling + const img = target.closest('img'); + if (img) { + zoomData = { src: img.src }; + return; + } + + const mermaidDiv = target.closest('.mermaid-diagram'); + if (mermaidDiv) { + const svg = mermaidDiv.querySelector('svg'); + if (svg) { + zoomData = { html: svg.outerHTML }; + return; + } + } + } async function toggleTaskCheckbox(checkbox: HTMLInputElement) { const tab = tabManager.activeTab; @@ -1092,7 +1086,7 @@ } else { try { const html = (await invoke('render_markdown', { content: tab.rawContent })) as string; - const processedInfo = processMarkdownHtml(html, ''); + const processedInfo = processMarkdownHtml(html, '', collapsedHeaders); tabManager.updateTabContent(tab.id, processedInfo); } catch (e) { console.error('Failed to render markdown for unsaved file', e); @@ -1181,78 +1175,13 @@ } async function exportAsHtml() { - if (!htmlContent) return; - const tab = tabManager.activeTab; - const defaultName = tab?.path ? tab.path.replace(/\.[^.]+$/, '.html') : 'export.html'; - - const selected = await save({ - filters: [{ name: 'HTML', extensions: ['html', 'htm'] }], - defaultPath: defaultName, + await _exportHtml({ + htmlContent: htmlContent, + markdownBody, + tabTitle: tab?.title || '', + tabPath: tab?.path || '', }); - if (!selected) return; - - // gather styles from the app - let styles = ''; - for (const sheet of document.styleSheets) { - try { - for (const rule of sheet.cssRules) { - styles += rule.cssText + '\n'; - } - } catch { - // cross-origin sheets - } - } - - const fullHtml = ` - - - - -${tab?.title || 'Export'} - - - -
-${markdownBody?.innerHTML || htmlContent} -
- -`; - - try { - await invoke('save_file_content', { path: selected, content: fullHtml }); - } catch (e) { - console.error('Failed to export HTML', e); - } - } - - function exportAsPdf() { - window.print(); } function handleNewFile() { @@ -1298,6 +1227,72 @@ ${markdownBody?.innerHTML || htmlContent} } } + async function saveImageAs(src: string) { + let realPath = ''; + if (src.startsWith('asset:')) { + try { + const url = new URL(src); + realPath = decodeURIComponent(url.pathname); + if (realPath.startsWith('/localhost/')) { + realPath = realPath.substring(11); + } else if (realPath.startsWith('/')) { + realPath = realPath.substring(1); + } + } catch (e) { + console.error('Failed to parse asset URL:', e); + } + } else if (src.startsWith('http')) { + try { + const response = await fetch(src); + const buffer = await response.arrayBuffer(); + const dest = await save({ + defaultPath: 'image.png', + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] + }); + if (dest) { + await invoke('save_file_binary', { path: dest, data: Array.from(new Uint8Array(buffer)) }); + addToast('Image saved successfully'); + } + } catch (e) { + addToast('Failed to save remote image', 'error'); + } + return; + } + + if (realPath) { + const ext = realPath.split('.').pop() || 'png'; + const dest = await save({ + defaultPath: `image.${ext}`, + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'] }] + }); + if (dest) { + try { + await invoke('copy_file', { src: realPath, dest }); + addToast('Image saved successfully'); + } catch (e) { + addToast(`Failed to save image: ${e}`, 'error'); + } + } + } + } + + async function saveDiagramAs(container: HTMLElement) { + const svg = container.querySelector('svg')?.outerHTML; + if (!svg) return; + const dest = await save({ + defaultPath: 'diagram.svg', + filters: [{ name: 'SVG Image', extensions: ['svg'] }] + }); + if (dest) { + try { + await invoke('save_file_content', { path: dest, content: svg }); + addToast('Diagram saved as SVG'); + } catch (e) { + addToast(`Failed to save diagram: ${e}`, 'error'); + } + } + } + function handleContextMenu(e: MouseEvent) { if (mode !== 'app') return; e.preventDefault(); @@ -1306,11 +1301,44 @@ ${markdownBody?.innerHTML || htmlContent} const hasSelection = selection ? selection.toString().length > 0 : false; const isInsideEditor = (e.target as HTMLElement).closest('.editor-container'); + // detect heading for copy ref + const heading = (e.target as HTMLElement).closest('h1, h2, h3, h4, h5, h6'); + let copyRefItem: any[] = []; + if (heading) { + const text = heading.textContent?.trim() || ''; + const tab = tabManager.activeTab; + const filename = tab?.path ? tab.path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '' : ''; + const ref = filename ? `[[${filename}#${text}]]` : `#${text}`; + copyRefItem = [ + { label: 'Copy Reference', onClick: () => invoke('clipboard_write_text', { text: ref }) }, + { separator: true }, + ]; + } + + const img = (e.target as HTMLElement).closest('img'); + let mediaItems: any[] = []; + if (img) { + mediaItems = [ + { label: 'Save Image As...', onClick: () => saveImageAs(img.src) }, + { separator: true } + ]; + } + + const mermaidDiag = (e.target as HTMLElement).closest('.mermaid-diagram'); + if (mermaidDiag) { + mediaItems = [ + { label: 'Save Diagram As SVG...', onClick: () => saveDiagramAs(mermaidDiag as HTMLElement) }, + { separator: true } + ]; + } + docContextMenu = { show: true, x: e.clientX, y: e.clientY, items: [ + ...copyRefItem, + ...mediaItems, ...(isEditing && isInsideEditor ? [ { label: 'Undo', shortcut: 'Ctrl+Z', onClick: () => editorPane?.undo() }, @@ -1358,7 +1386,7 @@ ${markdownBody?.innerHTML || htmlContent} text = text.replace(/↩.*$/, '').trim(); // remove backrefs if any if (text) { const rect = anchor.getBoundingClientRect(); - tooltip = { show: true, text, html: '', isFootnote: false, x: rect.left + rect.width / 2, y: rect.top - 8 }; + tooltip = { show: true, text, html: '', isFootnote: false, x: rect.left + rect.width / 2, y: rect.top - 8, align: 'top' }; return; } } @@ -1379,7 +1407,7 @@ ${markdownBody?.innerHTML || htmlContent} let fnHtml = clone.innerHTML.trim(); if (fnHtml) { const rect = anchor.getBoundingClientRect(); - tooltip = { show: true, text: '', html: fnHtml, isFootnote: true, x: rect.left + rect.width / 2, y: rect.top - 8 }; + tooltip = { show: true, text: '', html: fnHtml, isFootnote: true, x: rect.left + rect.width / 2, y: rect.top - 8, align: 'top' }; return; } } @@ -1387,7 +1415,7 @@ ${markdownBody?.innerHTML || htmlContent} if (anchor.href) { const rect = anchor.getBoundingClientRect(); - tooltip = { show: true, text: anchor.href, html: '', isFootnote: false, x: rect.left + rect.width / 2, y: rect.top - 8 }; + tooltip = { show: true, text: anchor.href, html: '', isFootnote: false, x: rect.left + rect.width / 2, y: rect.top - 8, align: 'top' }; } } } @@ -1453,7 +1481,7 @@ ${markdownBody?.innerHTML || htmlContent} debounceTimer = setTimeout(() => { invoke('render_markdown', { content: tab.rawContent }) .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path); + const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); tabManager.updateTabContent(tab.id, processed); tick().then(renderRichContent); }) @@ -2073,11 +2101,40 @@ ${markdownBody?.innerHTML || htmlContent} {/if} -
+
+ {#if isMarkdown && !showHome} +
+ + {/if}
{#if settings.showToc} -
- +
+ { const tab = tabManager.activeTab; const fn = tab?.path ? tab.path.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') || '' : ''; invoke('clipboard_write_text', { text: fn ? `[[${fn}#${text}]]` : `#${text}` }); }} />
{/if} @@ -2085,7 +2142,7 @@ ${markdownBody?.innerHTML || htmlContent}
+
{#if tooltip.isFootnote} {@html tooltip.html} {:else} @@ -2129,6 +2186,14 @@ ${markdownBody?.innerHTML || htmlContent} {/each}
+ {#if zoomData} + zoomData = null} + /> + {/if} + {#if isDragging}