diff --git a/CHANGELOG.md b/CHANGELOG.md index 488938f..dde4486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v2.4.0 +- Added split view +- Added scroll sync between editor and viewer + ## v2.3.6 - Fixed zoom handling in editor - Added ARM64 support diff --git a/README.md b/README.md index d897e47..8be2c84 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Right click on a markdown file and select "Open with" and select the downloaded ## Screenshots -![alt text](pics/image1.png) -![alt text](pics/image2.png) -![alt text](pics/image3.png) +![readme splitview demo](pics/image.png) +![codeblock demonstration](pics/image1.png) +![editor view](pics/image2.png) +![home page](pics/image3.png) diff --git a/package.json b/package.json index c4fcf30..d45e003 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markpad", - "version": "2.3.6", + "version": "2.4.0", "description": "", "type": "module", "scripts": { diff --git a/pics/image.png b/pics/image.png new file mode 100644 index 0000000..ee9df34 Binary files /dev/null and b/pics/image.png differ diff --git a/pics/image4.png b/pics/image4.png new file mode 100644 index 0000000..fa36b42 Binary files /dev/null and b/pics/image4.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a5eee45..00c38fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,9 +22,7 @@ async fn show_window(window: tauri::Window) { } #[tauri::command] -fn open_markdown(path: String) -> Result { - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - +fn convert_markdown(content: &str) -> String { let mut options = ComrakOptions { extension: ComrakExtensionOptions { strikethrough: true, @@ -42,9 +40,18 @@ fn open_markdown(path: String) -> Result { options.render.hardbreaks = true; options.render.sourcepos = true; - let html_output = markdown_to_html(&content, &options); + markdown_to_html(content, &options) +} + +#[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)) +} - Ok(html_output) +#[tauri::command] +fn render_markdown(content: String) -> String { + convert_markdown(&content) } #[tauri::command] @@ -406,6 +413,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ open_markdown, + render_markdown, send_markdown_path, read_file_content, save_file_content, diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 0da8a69..aa4c76e 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -2,6 +2,7 @@ import { invoke, convertFileSrc } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { onMount, tick, untrack } from 'svelte'; + import { fly } from 'svelte/transition'; import { openUrl } from '@tauri-apps/plugin-opener'; import { open, save, ask } from '@tauri-apps/plugin-dialog'; import Installer from './Installer.svelte'; @@ -28,10 +29,13 @@ let liveMode = $state(false); let isDragging = $state(false); + let isProgrammaticScroll = false; // derived from tab manager - let isEditing = $derived(tabManager.activeTab?.isEditing ?? false); - let rawContent = $derived(tabManager.activeTab?.rawContent ?? ''); + let activeTab = $derived(tabManager.activeTab); + let isEditing = $derived(activeTab?.isEditing ?? false); + let rawContent = $derived(activeTab?.rawContent ?? ''); + let isSplit = $derived(activeTab?.isSplit ?? false); // derived from tab manager let currentFile = $derived(tabManager.activeTab?.path ?? ''); @@ -40,11 +44,14 @@ let scrollTop = $derived(tabManager.activeTab?.scrollTop ?? 0); let isScrolled = $derived(scrollTop > 0); let windowTitle = $derived(tabManager.activeTab?.title ?? 'Markpad'); + let isScrollSynced = $derived(tabManager.activeTab?.isScrollSynced ?? false); let showHome = $state(false); // ui state let tooltip = $state({ show: false, text: '', x: 0, y: 0 }); + let caretEl: HTMLElement; + let caretAbsoluteTop = 0; let modalState = $state<{ show: boolean; title: string; @@ -116,11 +123,77 @@ } $effect(() => { - // Dismiss home view when switching tabs const _ = tabManager.activeTabId; 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'); + if (src && !src.startsWith('http') && !src.startsWith('data:')) { + img.setAttribute('src', convertFileSrc(resolvePath(filePath, src))); + } else if (src && 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); + } + } + } + + return doc.body.innerHTML; + } + async function loadMarkdown(filePath: string, options: { navigate?: boolean; skipTabManagement?: boolean } = {}) { showHome = false; try { @@ -146,71 +219,8 @@ if (isMarkdown) { if (tab) tab.isEditing = false; const html = (await invoke('open_markdown', { path: filePath })) as 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'); - if (src && !src.startsWith('http') && !src.startsWith('data:')) { - img.setAttribute('src', convertFileSrc(resolvePath(filePath, src))); - } else if (src && 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); - } - } - } - - tabManager.updateTabContent(activeId, doc.body.innerHTML); + const processedInfo = processMarkdownHtml(html, filePath); + tabManager.updateTabContent(activeId, processedInfo); } else { if (tab) tab.isEditing = true; const content = (await invoke('read_file_content', { path: filePath })) as string; @@ -265,7 +275,9 @@ $effect(() => { // Depend on the ID and body existence to trigger restore const id = tabManager.activeTabId; - if (id && markdownBody) { + const body = markdownBody; + + if (id && body) { untrack(() => { const tab = tabManager.tabs.find((t) => t.id === id); if (tab) { @@ -274,7 +286,7 @@ if (tab.anchorLine > 0) { // Interpolated Restore // Find element containing the anchor line - const children = Array.from(markdownBody.children) as HTMLElement[]; + const children = Array.from(body.children) as HTMLElement[]; for (const el of children) { const sourcepos = el.dataset.sourcepos; if (sourcepos) { @@ -294,7 +306,7 @@ // Calculate target pixel position // We want the anchor line to be roughly at offset 60 const targetOffset = el.offsetTop + el.offsetHeight * ratio - 60; - markdownBody.scrollTop = Math.max(0, targetOffset); + body.scrollTop = Math.max(0, targetOffset); scrolled = true; break; } @@ -304,11 +316,11 @@ } if (!scrolled) { - if (markdownBody.scrollHeight > markdownBody.clientHeight && tab.scrollPercentage > 0) { - const targetScroll = tab.scrollPercentage * (markdownBody.scrollHeight - markdownBody.clientHeight); - markdownBody.scrollTop = targetScroll; + if (body.scrollHeight > body.clientHeight && tab.scrollPercentage > 0) { + const targetScroll = tab.scrollPercentage * (body.scrollHeight - body.clientHeight); + body.scrollTop = targetScroll; } else { - markdownBody.scrollTop = tab.scrollTop; + body.scrollTop = tab.scrollTop; } } } @@ -316,10 +328,63 @@ } }); - // ... (helper functions if needed) ... + function scrollToLine(line: number, ratio: number = 0) { + if (!markdownBody) return; + + const children = Array.from(markdownBody.children) as HTMLElement[]; + for (const el of children) { + const sourcepos = el.dataset.sourcepos; + if (sourcepos) { + const [start, end] = sourcepos.split('-'); + const startLine = parseInt(start.split(':')[0]); + const endLine = parseInt(end.split(':')[0]); + + if (!isNaN(startLine) && !isNaN(endLine)) { + if (line >= startLine && line <= endLine) { + const totalLines = endLine - startLine; + let lineRatio = 0; + if (totalLines > 0) { + lineRatio = (line - startLine) / totalLines; + } + lineRatio = Math.max(0, Math.min(1, lineRatio)); + + const elementTop = el.offsetTop + el.offsetHeight * lineRatio; + + const viewportHeight = markdownBody.clientHeight; + const targetScroll = elementTop - viewportHeight * ratio; + + if (Math.abs(markdownBody.scrollTop - targetScroll) > 5) { + isProgrammaticScroll = true; + markdownBody.scrollTop = Math.max(0, targetScroll); + } + return; + } + } + } + } + } + + function handleEditorScrollSync(line: number, ratio: number = 0) { + if (tabManager.activeTab?.isScrollSynced) { + scrollToLine(line, ratio); + } + } function handleScroll(e: Event) { const target = e.target as HTMLElement; + + if (isProgrammaticScroll) { + isProgrammaticScroll = false; + if (tabManager.activeTabId) { + tabManager.updateTabScroll(tabManager.activeTabId, target.scrollTop); + } + return; + } + + if (tabManager.activeTab?.isScrollSynced) { + tabManager.toggleScrollSync(tabManager.activeTab.id); + } + if (tabManager.activeTabId) { // Update raw scroll pos tabManager.updateTabScroll(tabManager.activeTabId, target.scrollTop); @@ -483,7 +548,7 @@ async function saveContent(): Promise { const tab = tabManager.activeTab; - if (!tab || !tab.isEditing) return false; + if (!tab || (!tab.isEditing && !tab.isSplit)) return false; let targetPath = tab.path; @@ -636,52 +701,94 @@ } } + let debounceTimer: number; + + $effect(() => { + const tab = tabManager.activeTab; + if (tab && tab.isSplit && tab.rawContent !== undefined) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + invoke('render_markdown', { content: tab.rawContent }) + .then((html) => { + const processed = processMarkdownHtml(html as string, tab.path); + tabManager.updateTabContent(tab.id, processed); + tick().then(renderRichContent); + }) + .catch(console.error); + }, 16); + } + }); + + async function toggleSplitView(tabId: string) { + const tab = tabManager.tabs.find((t) => t.id === tabId); + if (!tab) return; + + if (!tab.isSplit) { + if (!tab.isEditing && !tab.rawContent && tab.path) { + try { + const content = (await invoke('read_file_content', { path: tab.path })) as string; + tab.rawContent = content; + tab.originalContent = content; + } catch (e) { + console.error('Failed to load raw content for split view', e); + } + } + tab.isSplit = true; + if (liveMode) toggleLiveMode(); + } else { + tab.isSplit = false; + } + } + function handleKeyDown(e: KeyboardEvent) { if (mode !== 'app') return; const cmdOrCtrl = e.ctrlKey || e.metaKey; + const key = e.key.toLowerCase(); + const code = e.code; + + const isSplit = tabManager.activeTab?.isSplit; - if (cmdOrCtrl && e.key === 'w') { + if (cmdOrCtrl && key === 'w') { e.preventDefault(); closeFile(); } - if (cmdOrCtrl && e.key === 't') { + if (cmdOrCtrl && !e.shiftKey && key === 't') { e.preventDefault(); tabManager.addHomeTab(); } - // Edit toggle - if (cmdOrCtrl && e.key === 'e') { + if (cmdOrCtrl && key === 'h') { e.preventDefault(); - toggleEdit(true); + if (tabManager.activeTabId) toggleSplitView(tabManager.activeTabId); } - // Save - if (cmdOrCtrl && e.key === 's') { - // e.preventDefault(); // Don't prevent default blindly? - // If we are in edit mode, Editor.svelte handles it usually, but if focus is not in editor... - if (isEditing) { + if (cmdOrCtrl && key === 'e') { + e.preventDefault(); + if (!isSplit) toggleEdit(true); + } + if (cmdOrCtrl && key === 's') { + if (isEditing || isSplit) { e.preventDefault(); saveContent(); } } - if (cmdOrCtrl && e.shiftKey && e.key === 'T') { + if (cmdOrCtrl && e.shiftKey && key === 't') { e.preventDefault(); handleUndoCloseTab(); } - if (cmdOrCtrl && e.key === 'Tab') { + if (cmdOrCtrl && code === 'Tab') { e.preventDefault(); tabManager.cycleTab(e.shiftKey ? 'prev' : 'next'); } - // Zoom shortcuts - if (cmdOrCtrl && (e.key === '=' || e.key === '+')) { + if (cmdOrCtrl && (key === '=' || key === '+')) { e.preventDefault(); zoomLevel = Math.min(zoomLevel + 10, 500); } - if (cmdOrCtrl && e.key === '-') { + if (cmdOrCtrl && key === '-') { e.preventDefault(); zoomLevel = Math.max(zoomLevel - 10, 25); } - if (cmdOrCtrl && e.key === '0') { + if (cmdOrCtrl && key === '0') { e.preventDefault(); zoomLevel = 100; } @@ -730,6 +837,55 @@ }); } + function startDrag(e: MouseEvent, tabId: string | null) { + if (!tabId) return; + e.preventDefault(); + const startX = e.clientX; + const tab = tabManager.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const startRatio = tab.splitRatio ?? 0.5; + const containerWidth = window.innerWidth; + + const onMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX; + const deltaRatio = deltaX / containerWidth; + tabManager.setSplitRatio(tabId, startRatio + deltaRatio); + }; + + const onUp = () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + }; + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + document.body.style.cursor = 'col-resize'; + } + + function getSplitTransition(node: Element, { isEditing, side }: { isEditing: boolean; side: 'left' | 'right' }) { + let shouldAnimate = false; + let x = 0; + + if (side === 'left') { + if (!isEditing) { + shouldAnimate = true; + x = -50; + } + } else { + if (isEditing) { + shouldAnimate = true; + x = 50; + } + } + + if (shouldAnimate) { + return fly(node, { x, duration: 250 }); + } + return { duration: 0 }; + } + onMount(() => { loadRecentFiles(); @@ -800,7 +956,7 @@ localStorage.setItem('recent-files', JSON.stringify(recentFiles)); } catch (e) { console.error('Failed to rename file', e); - await ask(`Failed to rename file: ${e}`, { title: 'Error', kind: 'error' }); + await askCustom(`Failed to rename file: ${e}`, { title: 'Error', kind: 'error' }); } } }), @@ -940,6 +1096,7 @@ ononpenFileLocation={openFileLocation} ontoggleLiveMode={toggleLiveMode} ontoggleEdit={() => toggleEdit()} + ontoggleSplit={() => tabManager.activeTabId && toggleSplitView(tabManager.activeTabId)} {isEditing} ondetach={handleDetach} ontabclick={() => (showHome = false)} @@ -972,6 +1129,7 @@ ononpenFileLocation={openFileLocation} ontoggleLiveMode={toggleLiveMode} ontoggleEdit={() => toggleEdit()} + ontoggleSplit={() => tabManager.activeTabId && toggleSplitView(tabManager.activeTabId)} {isEditing} ondetach={handleDetach} ontabclick={() => (showHome = false)} @@ -980,32 +1138,47 @@ canCloseTab(id).then((can) => { if (can) tabManager.closeTab(id); }); - }} /> + }} + {isScrollSynced} + ontoggleSync={() => tabManager.activeTabId && tabManager.toggleScrollSync(tabManager.activeTabId)} /> {#if tabManager.activeTab && (tabManager.activeTab.path !== '' || tabManager.activeTab.title !== 'Recents') && !showHome} {#key tabManager.activeTabId} -