Compare commits

..
2 Commits
Author SHA1 Message Date
Yuri Karamian 1a880a4f96 Add open-note trash action (#177) 2026-08-03 10:45:59 +02:00
Yuri Karamian f4e05ed9c4 Add Markdown source highlighting (#173) 2026-08-03 09:00:43 +02:00
8 changed files with 256 additions and 50 deletions
+2
View File
@@ -1398,6 +1398,7 @@ pub fn set_general_settings(
show_line_numbers: bool, show_line_numbers: bool,
show_link_arrows: bool, show_link_arrows: bool,
default_view_mode: bool, default_view_mode: bool,
new_notes_in_source_mode: bool,
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool, close_to_tray: bool,
enable_wiki_links: bool, enable_wiki_links: bool,
@@ -1432,6 +1433,7 @@ pub fn set_general_settings(
config.show_line_numbers = show_line_numbers; config.show_line_numbers = show_line_numbers;
config.show_link_arrows = show_link_arrows; config.show_link_arrows = show_link_arrows;
config.default_view_mode = default_view_mode; config.default_view_mode = default_view_mode;
config.new_notes_in_source_mode = new_notes_in_source_mode;
config.show_tray_icon = show_tray_icon; config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray; config.close_to_tray = close_to_tray;
config.enable_wiki_links = enable_wiki_links; config.enable_wiki_links = enable_wiki_links;
+3
View File
@@ -197,6 +197,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub default_view_mode: bool, pub default_view_mode: bool,
#[serde(default)] #[serde(default)]
pub new_notes_in_source_mode: bool,
#[serde(default)]
pub show_tray_icon: bool, pub show_tray_icon: bool,
#[serde(default)] #[serde(default)]
pub close_to_tray: bool, pub close_to_tray: bool,
@@ -315,6 +317,7 @@ impl Default for AppConfig {
ai_model: "claude-sonnet-4-6".to_string(), ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None, ai_writing_style: None,
default_view_mode: false, default_view_mode: false,
new_notes_in_source_mode: false,
show_tray_icon: false, show_tray_icon: false,
close_to_tray: false, close_to_tray: false,
enable_wiki_links: true, enable_wiki_links: true,
+2
View File
@@ -265,6 +265,7 @@ export async function setGeneralSettings(
showLineNumbers: boolean, showLineNumbers: boolean,
showLinkArrows: boolean, showLinkArrows: boolean,
defaultViewMode: boolean, defaultViewMode: boolean,
newNotesInSourceMode: boolean,
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean, closeToTray: boolean,
enableWikiLinks: boolean, enableWikiLinks: boolean,
@@ -291,6 +292,7 @@ export async function setGeneralSettings(
showLineNumbers, showLineNumbers,
showLinkArrows, showLinkArrows,
defaultViewMode, defaultViewMode,
newNotesInSourceMode,
showTrayIcon, showTrayIcon,
closeToTray, closeToTray,
enableWikiLinks, enableWikiLinks,
+26 -4
View File
@@ -61,7 +61,7 @@
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); const isMobile = $derived($platformIsMobile);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, deleteNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, getAppConfig, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isAndroid } from '$lib/platform'; import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -449,6 +449,28 @@
else if ($mobileView === 'notelist') $mobileView = 'sidebar'; else if ($mobileView === 'notelist') $mobileView = 'sidebar';
} }
async function trashOpenNote(path: string): Promise<boolean> {
if (path !== $activeNotePath || $viewerNote || $viewMode === 'trash') return false;
try {
await deleteNote(path);
if (Object.hasOwn($noteOrder, path)) {
const { [path]: _, ...rest } = $noteOrder;
$noteOrder = rest;
}
$notes = $notes.filter((note) => note.path !== path);
if ($activeNotePath === path) {
$activeNote = null;
$activeNotePath = null;
}
noteList?.refresh(true).catch((error) => console.error('Failed to refresh notes after trashing:', error));
if (isMobile) $mobileView = 'notelist';
return true;
} catch (error) {
console.error('Failed to move open note to Trash:', error);
return false;
}
}
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); } if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); } if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
@@ -467,7 +489,7 @@
// Ctrl/Cmd+Shift+Delete: move the open note to the trash. // Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) { if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault(); e.preventDefault();
noteList?.trashActiveNote(); editor?.moveOpenNoteToTrash();
return; return;
} }
@@ -951,7 +973,7 @@
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
</div> </div>
</div> </div>
@@ -1028,7 +1050,7 @@
{/if} {/if}
<div class="editor-panel"> <div class="editor-panel">
<Editor bind:this={editor} /> <Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
{#if $viewMode === 'tasks' && !taskNoteOpened} {#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder"> <div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+208 -39
View File
@@ -24,6 +24,8 @@
import TextAlign from '@tiptap/extension-text-align'; import TextAlign from '@tiptap/extension-text-align';
import { common, createLowlight } from 'lowlight'; import { common, createLowlight } from 'lowlight';
import powershell from 'highlight.js/lib/languages/powershell'; import powershell from 'highlight.js/lib/languages/powershell';
import hljs from 'highlight.js/lib/core';
import markdownLanguage from 'highlight.js/lib/languages/markdown';
import MarkdownIt from 'markdown-it'; import MarkdownIt from 'markdown-it';
import markdownItMark from 'markdown-it-mark'; import markdownItMark from 'markdown-it-mark';
import markdownItSup from 'markdown-it-sup'; import markdownItSup from 'markdown-it-sup';
@@ -55,7 +57,14 @@
import { isMobile, isAndroid } from '$lib/platform'; import { isMobile, isAndroid } from '$lib/platform';
import ResizeHandle from './ResizeHandle.svelte'; import ResizeHandle from './ResizeHandle.svelte';
let { onMoveToTrash = async () => false }: {
onMoveToTrash?: (path: string) => Promise<boolean>;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const sourceHighlighter = hljs.newInstance();
sourceHighlighter.registerLanguage('markdown', markdownLanguage);
const SOURCE_HIGHLIGHT_MAX_CHARS = 32_000;
// Track virtual keyboard height on mobile via visualViewport // Track virtual keyboard height on mobile via visualViewport
let keyboardHeight = $state(0); let keyboardHeight = $state(0);
@@ -68,6 +77,7 @@
let editorElement = $state<HTMLDivElement>(null!); let editorElement = $state<HTMLDivElement>(null!);
let sourceElement = $state<HTMLTextAreaElement>(null!); let sourceElement = $state<HTMLTextAreaElement>(null!);
let sourceHighlightElement = $state<HTMLPreElement>(null!);
const LARGE_DOC_CHARS = 100_000; const LARGE_DOC_CHARS = 100_000;
let isLargeDoc = $state(false); let isLargeDoc = $state(false);
let editor: Editor | null = null; let editor: Editor | null = null;
@@ -83,6 +93,10 @@
}); });
let editorReady = $state(false); let editorReady = $state(false);
let sourceContent = $state(''); let sourceContent = $state('');
let sourceHighlightHtml = $derived.by(() => {
if (sourceContent.length > SOURCE_HIGHLIGHT_MAX_CHARS) return escapeHtml(sourceContent);
return sourceHighlighter.highlight(sourceContent, { language: 'markdown', ignoreIllegals: true }).value;
});
let sourceHistory: Array<{ content: string; cursor: number }> = []; let sourceHistory: Array<{ content: string; cursor: number }> = [];
let sourceHistoryIndex = -1; let sourceHistoryIndex = -1;
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null; let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -93,6 +107,7 @@
let pendingContent = $state<string | null>(null); let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false; let ignoreNextUpdate = false;
let isLoadingNote = false; let isLoadingNote = false;
let trashingNote = $state(false);
let fixingBlobsPromise: Promise<void> = Promise.resolve(); let fixingBlobsPromise: Promise<void> = Promise.resolve();
let hasPendingBlobs = false; let hasPendingBlobs = false;
let lastSourceMode = $sourceMode; let lastSourceMode = $sourceMode;
@@ -123,6 +138,18 @@
}); });
} }
function syncSourceEditorScroll() {
if (sourceHighlightElement && sourceElement) {
sourceHighlightElement.scrollTop = sourceElement.scrollTop;
sourceHighlightElement.scrollLeft = sourceElement.scrollLeft;
}
if ($appConfig?.show_line_numbers && sourceElement) {
const clip = sourceElement.closest('.editor-body')?.querySelector('.line-numbers-clip');
const gutter = clip?.firstElementChild as HTMLElement | null;
if (gutter) gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
}
}
function clearTaskReveal() { function clearTaskReveal() {
if (taskRevealTimer) clearTimeout(taskRevealTimer); if (taskRevealTimer) clearTimeout(taskRevealTimer);
taskRevealTimer = null; taskRevealTimer = null;
@@ -2971,8 +2998,16 @@
return src; return src;
} }
let saveQueue: Promise<void> = Promise.resolve();
function queueSave(task: () => Promise<void>): Promise<void> {
const queued = saveQueue.then(task);
saveQueue = queued.catch(() => {});
return queued;
}
const autoSave = debounce(async () => { const autoSave = debounce(async () => {
if (get(viewerNote)) return; // never autosave external viewer files if (get(viewerNote) || trashingNote) return; // never autosave external viewer files or a note being trashed
if (!$activeNote || !$activeNotePath || !$editorDirty) return; if (!$activeNote || !$activeNotePath || !$editorDirty) return;
// Only fix blob images if a paste occurred (avoids full doc scan on every save) // Only fix blob images if a paste occurred (avoids full doc scan on every save)
if (hasPendingBlobs) { if (hasPendingBlobs) {
@@ -2980,38 +3015,65 @@
fixingBlobsPromise = fixBlobImages(); fixingBlobsPromise = fixBlobImages();
} }
await fixingBlobsPromise; await fixingBlobsPromise;
if (trashingNote || !$activeNote || !$activeNotePath) return;
try { try {
const path = $activeNotePath;
const note = $activeNote;
const body = $sourceMode const body = $sourceMode
? restoreTitleH1(sourceContent) ? restoreTitleH1(sourceContent)
: editorToMarkdown(); : editorToMarkdown();
// Safety: never save empty/near-empty body over a note that had real content // Safety: never save empty/near-empty body over a note that had real content
const trimmed = body.replace(/^#.*\n?/, '').trim(); const trimmed = body.replace(/^#.*\n?/, '').trim();
if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) { if (!trimmed && note.content && note.content.trim().length > 10) {
console.warn('Auto-save blocked: refusing to overwrite note with empty content'); console.warn('Auto-save blocked: refusing to overwrite note with empty content');
return; return;
} }
await saveNote($activeNotePath, $activeNote.meta, body); await queueSave(() => saveNote(path, note.meta, body));
$editorDirty = false; if ($activeNotePath === path) $editorDirty = false;
} catch (e) { } catch (e) {
console.error('Auto-save failed:', e); console.error('Auto-save failed:', e);
} }
}, isMobile ? 1500 : 500); }, isMobile ? 1500 : 500);
export async function forceSave() { export async function forceSave(allowWhileTrashing = false): Promise<boolean> {
if (get(viewerNote)) return; // viewer files are never written back if (get(viewerNote) || (trashingNote && !allowWhileTrashing)) return false;
if (!$activeNote || !$activeNotePath) return; if (!$activeNote || !$activeNotePath) return false;
await fixingBlobsPromise; await fixingBlobsPromise;
if (trashingNote && !allowWhileTrashing) return false;
if (!$activeNote || !$activeNotePath) return false;
try { try {
const path = $activeNotePath;
const note = $activeNote;
const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown(); const body = $sourceMode ? restoreTitleH1(sourceContent) : editorToMarkdown();
const trimmed = body.replace(/^#.*\n?/, '').trim(); const trimmed = body.replace(/^#.*\n?/, '').trim();
if (!trimmed && $activeNote.content && $activeNote.content.trim().length > 10) { if (!trimmed && note.content && note.content.trim().length > 10) {
console.warn('Force-save blocked: refusing to overwrite note with empty content'); console.warn('Force-save blocked: refusing to overwrite note with empty content');
return; return false;
} }
await saveNote($activeNotePath, $activeNote.meta, body); await queueSave(() => saveNote(path, note.meta, body));
$editorDirty = false; if ($activeNotePath === path) $editorDirty = false;
return true;
} catch (e) { } catch (e) {
console.error('Save failed:', e); console.error('Save failed:', e);
return false;
}
}
export async function moveOpenNoteToTrash(): Promise<boolean> {
if (trashingNote || !$activeNotePath || $viewerNote) return false;
const path = $activeNotePath;
const wasDirty = $editorDirty;
trashingNote = true;
try {
// Drain any save already in flight, then persist the latest editor state.
await saveQueue;
if (wasDirty && !(await forceSave(true))) return false;
if ($activeNotePath !== path) return false;
// A queued debounce observes this flag and cannot recreate the moved note.
$editorDirty = false;
return await onMoveToTrash(path);
} finally {
trashingNote = false;
} }
} }
@@ -3271,13 +3333,16 @@
const revealRequest = ++taskRevealRequest; const revealRequest = ++taskRevealRequest;
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null; const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
loadedPath = path; loadedPath = path;
lastSourceMode = $sourceMode;
isLoadingNote = true; isLoadingNote = true;
isLargeDoc = content.length > LARGE_DOC_CHARS; isLargeDoc = content.length > LARGE_DOC_CHARS;
// Apply default view mode when switching notes - but new notes always open in edit mode. // Viewer mode (external file) always forces read-only. New notes stay editable and
// Viewer mode (external file) always forces read-only. // can opt into source mode without changing how existing notes choose their mode.
const isViewer = !!get(viewerNote); const isViewer = !!get(viewerNote);
const isNewNote = $activeNote?.meta.title === 'Untitled' && !content.replace(/^---[\s\S]*?---\s*/, '').trim(); const isNewNote = $activeNote?.meta.title === 'Untitled' && !content.replace(/^---[\s\S]*?---\s*/, '').trim();
if (isNewNote && ($appConfig?.new_notes_in_source_mode ?? false)) {
$sourceMode = true;
}
lastSourceMode = $sourceMode;
const shouldBeReadOnly = isViewer ? true : (isNewNote ? false : ($appConfig?.default_view_mode ?? false)); const shouldBeReadOnly = isViewer ? true : (isNewNote ? false : ($appConfig?.default_view_mode ?? false));
$readOnly = shouldBeReadOnly; $readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly); if (editor) editor.setEditable(!shouldBeReadOnly);
@@ -6025,6 +6090,13 @@
}} }}
/> />
</div> </div>
{#if isMobile}
<button type="button" class="icon-btn editor-trash-btn mobile" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
</svg>
</button>
{/if}
{#if !isMobile} {#if !isMobile}
<div class="toolbar-actions"> <div class="toolbar-actions">
{#if $canGoBack || $canGoForward} {#if $canGoBack || $canGoForward}
@@ -6173,6 +6245,11 @@
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" /> <path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" />
</svg> </svg>
</button> </button>
<button type="button" class="icon-btn editor-trash-btn" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
</svg>
</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -6240,9 +6317,10 @@
<div class="editor-body"> <div class="editor-body">
{#if isMobile} {#if isMobile}
<!-- Mobile: both views always in DOM, toggled via display to avoid slow editor re-creation --> <!-- Mobile: both views always in DOM, toggled via display to avoid slow editor re-creation -->
<div class="source-editor-layer" style={$sourceMode ? '' : 'display:none'}>
<pre class="source-highlight" aria-hidden="true" bind:this={sourceHighlightElement}><code>{@html sourceHighlightHtml}</code></pre>
<textarea <textarea
class="source-editor" class="source-editor"
style={$sourceMode ? '' : 'display:none'}
bind:this={sourceElement} bind:this={sourceElement}
bind:value={sourceContent} bind:value={sourceContent}
readonly={$readOnly} readonly={$readOnly}
@@ -6282,8 +6360,10 @@
return; return;
} }
}} }}
onscroll={syncSourceEditorScroll}
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }}></div>
{:else} {:else}
@@ -6298,6 +6378,8 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="source-editor-layer" class:with-line-numbers={$appConfig?.show_line_numbers}>
<pre class="source-highlight" aria-hidden="true" bind:this={sourceHighlightElement}><code>{@html sourceHighlightHtml}</code></pre>
<textarea <textarea
class="source-editor" class="source-editor"
class:with-line-numbers={$appConfig?.show_line_numbers} class:with-line-numbers={$appConfig?.show_line_numbers}
@@ -6381,17 +6463,10 @@
autoSave(); autoSave();
} }
}} }}
onscroll={() => { onscroll={syncSourceEditorScroll}
if ($appConfig?.show_line_numbers) {
const clip = sourceElement?.previousElementSibling as HTMLElement;
const gutter = clip?.firstElementChild as HTMLElement;
if (gutter) {
gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
}
}
}}
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
</div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
@@ -8133,6 +8208,17 @@
color: var(--text-primary); color: var(--text-primary);
} }
.editor-trash-btn:hover,
.editor-trash-btn:focus-visible {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.editor-trash-btn:disabled {
opacity: 0.45;
cursor: default;
}
.icon-btn.active { .icon-btn.active {
color: var(--text-accent); color: var(--text-accent);
background: var(--accent-light); background: var(--accent-light);
@@ -8587,36 +8673,74 @@
.outline-level-5 { padding-left: 62px; } .outline-level-5 { padding-left: 62px; }
.outline-level-6 { padding-left: 74px; } .outline-level-6 { padding-left: 74px; }
.source-editor-layer {
position: relative;
width: 100%;
height: 100%;
max-width: var(--editor-content-width, none);
margin: 0 auto;
}
.source-highlight,
.source-editor { .source-editor {
box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none; border: none;
background: none;
color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: 1.3; line-height: 1.3;
resize: none;
outline: none;
padding: 0 0 var(--editor-scroll-past-end, 65vh); padding: 0 0 var(--editor-scroll-past-end, 65vh);
margin: 0 auto;
max-width: var(--editor-content-width, none);
user-select: text;
/* Wrap long lines instead of horizontal-scrolling (matches mobile). (issue #100) */
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
}
.source-highlight {
position: absolute;
inset: 0;
z-index: 0;
margin: 0;
overflow: hidden;
pointer-events: none;
user-select: none;
color: var(--text-primary);
}
.source-highlight code {
font: inherit;
white-space: inherit;
color: inherit;
}
.source-editor {
position: absolute;
inset: 0;
z-index: 1;
background: transparent;
color: transparent;
caret-color: var(--text-primary);
resize: none;
outline: none;
user-select: text;
overflow-x: hidden; overflow-x: hidden;
} }
.source-editor.with-line-numbers { .source-editor-layer.with-line-numbers {
max-width: none;
margin: 0;
}
.source-editor.with-line-numbers,
.source-editor-layer.with-line-numbers .source-highlight {
padding-left: 48px; padding-left: 48px;
/* The line-number gutter has one fixed row per line, so wrapping would desync it. /* The line-number gutter has one fixed row per line, so wrapping would desync it.
Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */ Keep no-wrap (horizontal scroll) whenever line numbers are on. (issue #100) */
white-space: pre; white-space: pre;
word-break: normal;
}
.source-editor.with-line-numbers {
overflow-x: auto; overflow-x: auto;
/* The line-number gutter is pinned to the left edge, so don't center/cap here. (#137) */
max-width: none;
margin: 0;
} }
.line-numbers-clip { .line-numbers-clip {
@@ -8646,6 +8770,36 @@
padding-right: 12px; padding-right: 12px;
} }
/* Markdown source highlighting stays color-only so glyph metrics match the textarea caret. */
:global(.source-highlight .hljs-section),
:global(.source-highlight .hljs-strong) {
color: var(--accent);
font-weight: inherit;
}
:global(.source-highlight .hljs-string),
:global(.source-highlight .hljs-link) {
color: color-mix(in srgb, var(--accent) 78%, var(--text-primary));
}
:global(.source-highlight .hljs-bullet),
:global(.source-highlight .hljs-symbol),
:global(.source-highlight .hljs-meta),
:global(.source-highlight .hljs-attr) {
color: var(--success);
}
:global(.source-highlight .hljs-quote),
:global(.source-highlight .hljs-code) {
color: var(--text-secondary);
font-style: inherit;
}
:global(.source-highlight .hljs-emphasis) {
color: color-mix(in srgb, var(--accent) 60%, var(--text-primary));
font-style: inherit;
}
.tiptap-wrapper { .tiptap-wrapper {
height: 100%; height: 100%;
user-select: text; user-select: text;
@@ -11204,9 +11358,9 @@
.editor-container.mobile .editor-toolbar { .editor-container.mobile .editor-toolbar {
padding: 8px 16px 6px 16px; padding: 8px 16px 6px 16px;
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: row;
align-items: stretch; align-items: center;
gap: 2px; gap: 8px;
} }
.toolbar-actions.mobile { .toolbar-actions.mobile {
@@ -11231,6 +11385,20 @@
padding: 4px 0; padding: 4px 0;
} }
.editor-container.mobile .editor-trash-btn.mobile {
width: 44px;
height: 44px;
padding: 0;
flex-shrink: 0;
justify-content: center;
border-radius: 8px;
}
.editor-container.mobile .editor-trash-btn.mobile:active {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.editor-container.mobile .editor-body-wrapper { .editor-container.mobile .editor-body-wrapper {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -11326,6 +11494,7 @@
font-size: var(--editor-font-size, 16px) !important; font-size: var(--editor-font-size, 16px) !important;
} }
.editor-container.mobile .source-highlight,
.editor-container.mobile .source-editor { .editor-container.mobile .source-editor {
padding: 8px 16px 220px; padding: 8px 16px 220px;
font-size: var(--editor-font-size, 15px); font-size: var(--editor-font-size, 15px);
-6
View File
@@ -519,12 +519,6 @@
} }
} }
// Move the currently open note to the trash (keyboard shortcut from the editor).
export async function trashActiveNote() {
if ($viewMode === 'trash' || !$activeNotePath) return;
const note = $notes.find(n => n.path === $activeNotePath);
if (note) await handleDelete(note);
}
async function handleRestore(note: NoteEntry) { async function handleRestore(note: NoteEntry) {
contextMenu = null; contextMenu = null;
+14 -1
View File
@@ -717,6 +717,7 @@
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false); let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let showLinkArrows = $state($appConfig?.show_link_arrows ?? true); let showLinkArrows = $state($appConfig?.show_link_arrows ?? true);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false); let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let newNotesInSourceMode = $state($appConfig?.new_notes_in_source_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false); let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false); let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true); let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
@@ -789,6 +790,7 @@
$appConfig.show_line_numbers = showLineNumbers; $appConfig.show_line_numbers = showLineNumbers;
$appConfig.show_link_arrows = showLinkArrows; $appConfig.show_link_arrows = showLinkArrows;
$appConfig.default_view_mode = defaultViewMode; $appConfig.default_view_mode = defaultViewMode;
$appConfig.new_notes_in_source_mode = newNotesInSourceMode;
$appConfig.show_tray_icon = showTrayIcon; $appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray; $appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $appConfig.enable_wiki_links = enableWikiLinks;
@@ -798,7 +800,7 @@
$appConfig.show_daily_notes = showDailyNotes; $appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash; $appConfig.show_trash = showTrash;
} }
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash) setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
.catch((e) => console.error('Failed to save general settings:', e)); .catch((e) => console.error('Failed to save general settings:', e));
} }
@@ -988,6 +990,7 @@
pdfPreview = $appConfig.pdf_preview ?? false; pdfPreview = $appConfig.pdf_preview ?? false;
pdfHeight = $appConfig.pdf_height ?? 600; pdfHeight = $appConfig.pdf_height ?? 600;
titleMode = $appConfig.title_mode ?? 'input'; titleMode = $appConfig.title_mode ?? 'input';
newNotesInSourceMode = $appConfig.new_notes_in_source_mode ?? false;
showAllNotes = $appConfig.show_all_notes ?? true; showAllNotes = $appConfig.show_all_notes ?? true;
showQuickAccess = $appConfig.show_quick_access ?? true; showQuickAccess = $appConfig.show_quick_access ?? true;
showTasks = $appConfig.show_tasks ?? true; showTasks = $appConfig.show_tasks ?? true;
@@ -1309,8 +1312,18 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Open new notes in source mode</span>
<span class="setting-desc">Start empty notes in the plain-text Markdown editor instead of rich-text mode</span>
</span>
<button class="toggle-switch" class:on={newNotesInSourceMode} onclick={() => { newNotesInSourceMode = !newNotesInSourceMode; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Wiki Links & Graph</h3> <h3>Wiki Links & Graph</h3>
<label class="setting-toggle"> <label class="setting-toggle">
+1
View File
@@ -129,6 +129,7 @@ export interface AppConfig {
ai_model: string; ai_model: string;
ai_writing_style: string | null; ai_writing_style: string | null;
default_view_mode: boolean; default_view_mode: boolean;
new_notes_in_source_mode: boolean;
show_tray_icon: boolean; show_tray_icon: boolean;
close_to_tray: boolean; close_to_tray: boolean;
enable_wiki_links: boolean; enable_wiki_links: boolean;