From 31adc64390d7baabbeb94ba916f1e83332228721 Mon Sep 17 00:00:00 2001 From: Alec Ames Date: Tue, 31 Mar 2026 01:04:33 -0400 Subject: [PATCH 1/3] fix: improve large file load times by loading first 50kb immediately --- src-tauri/src/lib.rs | 27 +++++++++++++++ src/lib/MarkdownViewer.svelte | 65 +++++++++++++++++++++++++++++++---- src/lib/stores/tabs.svelte.ts | 2 +- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03c02e2..27726a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,32 @@ fn open_markdown(path: String) -> Result { Ok(convert_markdown(&content)) } +#[tauri::command] +fn open_markdown_preview(path: String, max_bytes: usize) -> Result<(String, String, bool), String> { + use std::io::Read; + let mut f = fs::File::open(&path).map_err(|e| e.to_string())?; + + let metadata = f.metadata().map_err(|e| e.to_string())?; + if metadata.len() <= max_bytes as u64 { + let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + let html = convert_markdown(&content); + return Ok((html, content, true)); + } + + let mut vec_buf = vec![0; max_bytes]; + let n = f.read(&mut vec_buf).map_err(|e| e.to_string())?; + vec_buf.truncate(n); + + let mut preview_content = String::from_utf8_lossy(&vec_buf).into_owned(); + if !preview_content.ends_with('\n') { + preview_content.push('\n'); + } + preview_content.push_str("\n*... Loading remaining content ...*\n"); + + let html = convert_markdown(&preview_content); + Ok((html, preview_content, false)) +} + #[tauri::command] fn render_markdown(content: String) -> String { convert_markdown(&content) @@ -844,6 +870,7 @@ pub fn run() { clipboard_read_text, clipboard_read_image, open_markdown, + open_markdown_preview, render_markdown, send_markdown_path, read_file_content, diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index e621aad..77eb294 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -458,13 +458,40 @@ import { t } from './utils/i18n.js'; if (tab && !options.preserveEditState && !existing) { tab.isEditing = settings.startInEditor; } - const [html, content] = await Promise.all([ - invoke('open_markdown', { path: filePath }) as Promise, - invoke('read_file_content', { path: filePath }) as Promise - ]); + const [html, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; const processedInfo = processMarkdownHtml(html, filePath, collapsedHeaders); tabManager.updateTabContent(activeId, processedInfo); tabManager.setTabRawContent(activeId, content); + + if (!isFull) { + Promise.all([ + invoke('open_markdown', { path: filePath }) as Promise, + invoke('read_file_content', { path: filePath }) as Promise + ]).then(([fullHtml, fullContent]) => { + const applyFull = () => { + if (isScrolling) { + setTimeout(applyFull, 100); + return; + } + if (tabManager.tabs.find((t) => t.id === activeId)?.path === filePath) { + const fullProcessed = processMarkdownHtml(fullHtml, filePath, collapsedHeaders); + tabManager.updateTabContent(activeId, fullProcessed); + tabManager.setTabRawContent(activeId, fullContent); + if (tabManager.activeTabId === activeId) { + tick().then(() => { + setTimeout(renderRichContent, 10); + }); + } + } + }; + + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(applyFull, { timeout: 2000 }); + } else { + setTimeout(applyFull, 100); + } + }).catch(console.error); + } } else { if (tab) tab.isEditing = true; const content = (await invoke('read_file_content', { path: filePath })) as string; @@ -758,9 +785,18 @@ import { t } from './utils/i18n.js'; } } + let isScrolling = $state(false); + let scrollIdleTimer: ReturnType; + function handleScroll(e: Event) { const target = e.target as HTMLElement; + isScrolling = true; + clearTimeout(scrollIdleTimer); + scrollIdleTimer = setTimeout(() => { + isScrolling = false; + }, 300); + if (isProgrammaticScroll) { isProgrammaticScroll = false; if (tabManager.activeTabId) { @@ -1765,6 +1801,19 @@ import { t } from './utils/i18n.js'; const savedData = localStorage.getItem('savedTabsData'); if (savedData) { tabManager.restoreState(savedData); + for (const tab of tabManager.tabs) { + if (!tab.content && tab.rawContent) { + invoke('render_markdown', { content: tab.rawContent }) + .then((html) => { + const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + tabManager.updateTabContent(tab.id, processed); + if (tabManager.activeTabId === tab.id) { + tick().then(renderRichContent); + } + }) + .catch(console.error); + } + } } } @@ -1867,8 +1916,12 @@ import { t } from './utils/i18n.js'; if (isForceExiting) return; if (settings.restoreStateOnReopen) { - const stateStr = tabManager.serializeState(); - localStorage.setItem('savedTabsData', stateStr); + try { + const stateStr = tabManager.serializeState(); + localStorage.setItem('savedTabsData', stateStr); + } catch (e) { + console.error('Failed to save state on close:', e); + } return; } diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index 3b54f2b..a442744 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -48,7 +48,7 @@ class TabManager { serializeState(): string { const stateData = { activeTabId: this.activeTabId, - tabs: this.tabs.map(t => ({ ...t, editorViewState: null })) + tabs: this.tabs.map(t => ({ ...t, editorViewState: null, content: '' })) }; return JSON.stringify(stateData); } From ff8135724b47724ce42747b6cf84753ae85aabcb Mon Sep 17 00:00:00 2001 From: Alec Ames Date: Tue, 31 Mar 2026 01:32:02 -0400 Subject: [PATCH 2/3] fix: fixed TOC blank space on non markdown files --- src/lib/MarkdownViewer.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 77eb294..ecb73b9 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2143,9 +2143,9 @@ import { t } from './utils/i18n.js';
+ class:has-pinned-toc={isMarkdown && settings.pinnedToc && settings.showToc} + class:toc-on-left={isMarkdown && settings.tocSide === 'left'} + class:toc-on-right={isMarkdown && settings.tocSide === 'right'}>
{#if isEditing || isSplit} From 642ca8fe1586dfc54c92efb6fb9f68c77bd570f5 Mon Sep 17 00:00:00 2001 From: Alec Ames Date: Tue, 31 Mar 2026 02:21:31 -0400 Subject: [PATCH 3/3] feat: loading and caching of TOC and large documents between tab switch --- src-tauri/src/lib.rs | 58 ++++++++++++---------- src/lib/MarkdownViewer.svelte | 92 ++++++++++++++++++++++++++++++----- src/lib/utils/i18n.ts | 25 +++++++--- src/styles.css | 4 ++ 4 files changed, 133 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27726a8..9a2cdb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,40 +159,48 @@ fn convert_markdown(content: &str) -> String { } #[tauri::command] -fn open_markdown(path: String) -> Result { - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - Ok(convert_markdown(&content)) +async fn open_markdown(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + Ok(convert_markdown(&content)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) } #[tauri::command] -fn open_markdown_preview(path: String, max_bytes: usize) -> Result<(String, String, bool), String> { - use std::io::Read; - let mut f = fs::File::open(&path).map_err(|e| e.to_string())?; - - let metadata = f.metadata().map_err(|e| e.to_string())?; - if metadata.len() <= max_bytes as u64 { - let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; - let html = convert_markdown(&content); - return Ok((html, content, true)); - } +async fn open_markdown_preview(path: String, max_bytes: usize) -> Result<(String, String, bool), String> { + tauri::async_runtime::spawn_blocking(move || { + use std::io::Read; + let mut f = fs::File::open(&path).map_err(|e| e.to_string())?; + + let metadata = f.metadata().map_err(|e| e.to_string())?; + if metadata.len() <= max_bytes as u64 { + let content = fs::read_to_string(&path).map_err(|e| e.to_string())?; + let html = convert_markdown(&content); + return Ok((html, content, true)); + } - let mut vec_buf = vec![0; max_bytes]; - let n = f.read(&mut vec_buf).map_err(|e| e.to_string())?; - vec_buf.truncate(n); + let mut vec_buf = vec![0; max_bytes]; + let n = f.read(&mut vec_buf).map_err(|e| e.to_string())?; + vec_buf.truncate(n); - let mut preview_content = String::from_utf8_lossy(&vec_buf).into_owned(); - if !preview_content.ends_with('\n') { - preview_content.push('\n'); - } - preview_content.push_str("\n*... Loading remaining content ...*\n"); + let preview_content = String::from_utf8_lossy(&vec_buf).into_owned(); - let html = convert_markdown(&preview_content); - Ok((html, preview_content, false)) + let html = convert_markdown(&preview_content); + Ok((html, preview_content, false)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) } #[tauri::command] -fn render_markdown(content: String) -> String { - convert_markdown(&content) +async fn render_markdown(content: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + Ok(convert_markdown(&content)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) } #[tauri::command] diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index ecb73b9..09f436d 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -112,6 +112,9 @@ import { t } from './utils/i18n.js'; let windowTitle = $derived(tabManager.activeTab?.title ?? 'Markpad'); let isScrollSynced = $derived(tabManager.activeTab?.isScrollSynced ?? false); + let loadingTabs = $state([]); + let isAtBottom = $state(false); + let showHome = $state(false); let isFullWidth = $state(localStorage.getItem('isFullWidth') === 'true'); let viewerWidth = $state(0); @@ -464,24 +467,37 @@ import { t } from './utils/i18n.js'; tabManager.setTabRawContent(activeId, content); if (!isFull) { + loadingTabs = [...loadingTabs, activeId]; + tick().then(() => { + if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100; + }); Promise.all([ invoke('open_markdown', { path: filePath }) as Promise, invoke('read_file_content', { path: filePath }) as Promise ]).then(([fullHtml, fullContent]) => { const applyFull = () => { - if (isScrolling) { - setTimeout(applyFull, 100); - return; - } - if (tabManager.tabs.find((t) => t.id === activeId)?.path === filePath) { - const fullProcessed = processMarkdownHtml(fullHtml, filePath, collapsedHeaders); - tabManager.updateTabContent(activeId, fullProcessed); - tabManager.setTabRawContent(activeId, fullContent); - if (tabManager.activeTabId === activeId) { - tick().then(() => { - setTimeout(renderRichContent, 10); - }); + try { + if (isScrolling) { + setTimeout(applyFull, 100); + return; + } + if (tabManager.tabs.find((t) => t.id === activeId)?.path === filePath) { + const fullProcessed = processMarkdownHtml(fullHtml, filePath, collapsedHeaders); + tabManager.updateTabContent(activeId, fullProcessed); + tabManager.setTabRawContent(activeId, fullContent); + loadingTabs = loadingTabs.filter((id) => id !== activeId); + if (tabManager.activeTabId === activeId) { + tick().then(() => { + setTimeout(renderRichContent, 10); + }); + } + } else { + loadingTabs = loadingTabs.filter((id) => id !== activeId); } + } catch (applyErr) { + console.error("applyFull error:", applyErr); + addToast('Error processing full markdown: ' + String(applyErr), 'error'); + loadingTabs = loadingTabs.filter((id) => id !== activeId); } }; @@ -490,7 +506,11 @@ import { t } from './utils/i18n.js'; } else { setTimeout(applyFull, 100); } - }).catch(console.error); + }).catch((e) => { + console.error("Promise.all error:", e); + addToast('Backend Error loading full markdown: ' + String(e), 'error'); + loadingTabs = loadingTabs.filter((id) => id !== activeId); + }); } } else { if (tab) tab.isEditing = true; @@ -791,6 +811,8 @@ import { t } from './utils/i18n.js'; function handleScroll(e: Event) { const target = e.target as HTMLElement; + isAtBottom = Math.abs(target.scrollHeight - target.scrollTop - target.clientHeight) < 100; + isScrolling = true; clearTimeout(scrollIdleTimer); scrollIdleTimer = setTimeout(() => { @@ -1529,12 +1551,15 @@ import { t } from './utils/i18n.js'; $effect(() => { const tab = tabManager.activeTab; if (tab && (tab.isSplit || (isEditing && settings.showToc)) && tab.rawContent !== undefined) { + if ((tab as any)._lastRenderedRawContent === tab.rawContent) return; + clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { invoke('render_markdown', { content: tab.rawContent }) .then((html) => { const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); tabManager.updateTabContent(tab.id, processed); + (tab as any)._lastRenderedRawContent = tab.rawContent; tick().then(renderRichContent); }) .catch(console.error); @@ -2198,6 +2223,12 @@ import { t } from './utils/i18n.js'; tabindex="-1" style="outline: none; font-family: {settings.previewFont}, sans-serif; font-size: {settings.previewFontSize}px; flex: 1;"> + {#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom} +
+
+ {t('common.loadingFullDocument', settings.language)} +
+ {/if}
@@ -2377,9 +2408,44 @@ import { t } from './utils/i18n.js'; padding: 50px clamp(calc(calc(50% - 390px)), 5vw, 50px); height: 100%; overflow-y: auto; + overflow-x: hidden; transform: translate3d(0, 0, 0); } + .loading-chip { + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--color-canvas-overlay); + border: 1px solid var(--color-border-default); + border-radius: 20px; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 10px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + color: var(--color-fg-muted); + font-size: 13px; + font-family: var(--win-font), sans-serif; + } + + .loading-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--color-border-muted); + border-top-color: var(--color-accent-fg); + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + @media print { .markdown-body { height: auto !important; diff --git a/src/lib/utils/i18n.ts b/src/lib/utils/i18n.ts index b2af334..4b03c23 100644 --- a/src/lib/utils/i18n.ts +++ b/src/lib/utils/i18n.ts @@ -309,7 +309,8 @@ export const translations: Record = { common: { close: 'Close', minimize: 'Minimize', - maximize: 'Maximize' + maximize: 'Maximize', + loadingFullDocument: 'Loading full document...' } }, 'zh-CN': { @@ -5577,15 +5578,23 @@ export const translations: Record = { export function t(key: string, lang: LanguageCode = 'en'): string { const keys = key.split('.'); - let result: any = translations[lang]; - for (const k of keys) { - if (result && typeof result === 'object' && k in result) { - result = result[k]; - } else { - return key; // Fallback to key if translation not found + const getValue = (dict: any) => { + let res = dict; + for (const k of keys) { + if (res && typeof res === 'object' && k in res) res = res[k]; + else return undefined; } + return typeof res === 'string' ? res : undefined; + }; + + let result = getValue(translations[lang]); + if (result !== undefined) return result; + + if (lang !== 'en') { + result = getValue(translations['en']); + if (result !== undefined) return result; } - return typeof result === 'string' ? result : key; + return key; } diff --git a/src/styles.css b/src/styles.css index 45c9d6c..a54356d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -191,6 +191,10 @@ body { height: 8px; } +::-webkit-scrollbar-corner { + background: transparent; +} + ::-webkit-scrollbar-track { background: transparent; }