Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,48 @@ fn convert_markdown(content: &str) -> String {
}

#[tauri::command]
fn open_markdown(path: String) -> Result<String, String> {
let content = fs::read_to_string(path).map_err(|e| e.to_string())?;
Ok(convert_markdown(&content))
async fn open_markdown(path: String) -> Result<String, String> {
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 render_markdown(content: String) -> String {
convert_markdown(&content)
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 preview_content = String::from_utf8_lossy(&vec_buf).into_owned();

let html = convert_markdown(&preview_content);
Ok((html, preview_content, false))
})
.await
.unwrap_or_else(|e| Err(e.to_string()))
}

#[tauri::command]
async fn render_markdown(content: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
Ok(convert_markdown(&content))
})
.await
.unwrap_or_else(|e| Err(e.to_string()))
}

#[tauri::command]
Expand Down Expand Up @@ -844,6 +878,7 @@ pub fn run() {
clipboard_read_text,
clipboard_read_image,
open_markdown,
open_markdown_preview,
render_markdown,
send_markdown_path,
read_file_content,
Expand Down
137 changes: 128 additions & 9 deletions src/lib/MarkdownViewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
let isAtBottom = $state(false);

let showHome = $state(false);
let isFullWidth = $state(localStorage.getItem('isFullWidth') === 'true');
let viewerWidth = $state(0);
Expand Down Expand Up @@ -458,13 +461,57 @@ 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<string>,
invoke('read_file_content', { path: filePath }) as Promise<string>
]);
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) {
loadingTabs = [...loadingTabs, activeId];
tick().then(() => {
if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100;
});
Promise.all([
invoke('open_markdown', { path: filePath }) as Promise<string>,
invoke('read_file_content', { path: filePath }) as Promise<string>
]).then(([fullHtml, fullContent]) => {
const applyFull = () => {
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);
}
};

if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(applyFull, { timeout: 2000 });
} else {
setTimeout(applyFull, 100);
}
}).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;
const content = (await invoke('read_file_content', { path: filePath })) as string;
Expand Down Expand Up @@ -758,9 +805,20 @@ import { t } from './utils/i18n.js';
}
}

let isScrolling = $state(false);
let scrollIdleTimer: ReturnType<typeof setTimeout>;

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(() => {
isScrolling = false;
}, 300);

if (isProgrammaticScroll) {
isProgrammaticScroll = false;
if (tabManager.activeTabId) {
Expand Down Expand Up @@ -1493,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);
Expand Down Expand Up @@ -1765,6 +1826,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);
}
}
}
}

Expand Down Expand Up @@ -1867,8 +1941,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;
}

Expand Down Expand Up @@ -2090,9 +2168,9 @@ import { t } from './utils/i18n.js';
<div class="layout-container"
class:split={isSplit}
class:editing={isEditing}
class:has-pinned-toc={settings.pinnedToc && settings.showToc}
class:toc-on-left={settings.tocSide === 'left'}
class:toc-on-right={settings.tocSide === 'right'}>
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'}>
<!-- Editor Pane -->
<div bind:this={editorPaneEl} class="pane editor-pane" class:active={isEditing || isSplit} style="flex: {isSplit ? tabManager.activeTab.splitRatio : isEditing ? 1 : 0}">
{#if isEditing || isSplit}
Expand Down Expand Up @@ -2145,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;">
</article>
{#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom}
<div class="loading-chip" transition:fly={{ y: 20, duration: 300, easing: cubicOut }}>
<div class="loading-spinner"></div>
<span>{t('common.loadingFullDocument', settings.language)}</span>
</div>
{/if}
</div>
</div>

Expand Down Expand Up @@ -2324,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;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/stores/tabs.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
25 changes: 17 additions & 8 deletions src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ export const translations: Record<LanguageCode, Translation> = {
common: {
close: 'Close',
minimize: 'Minimize',
maximize: 'Maximize'
maximize: 'Maximize',
loadingFullDocument: 'Loading full document...'
}
},
'zh-CN': {
Expand Down Expand Up @@ -5577,15 +5578,23 @@ export const translations: Record<LanguageCode, Translation> = {

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;
}
4 changes: 4 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ body {
height: 8px;
}

::-webkit-scrollbar-corner {
background: transparent;
}

::-webkit-scrollbar-track {
background: transparent;
}
Expand Down
Loading