v1.2.8 - fix url links, tab indent, todo state, graph rework, collapsible sections, nav history, page breaks, folder/tag display

This commit is contained in:
Yuri Karamian
2026-03-27 14:05:59 +01:00
parent d46012b650
commit 7624c42e02
18 changed files with 1187 additions and 361 deletions
+4
View File
@@ -267,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path });
}
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo(
source: string,
destination: string,
+11 -24
View File
@@ -34,8 +34,10 @@
tags,
notes,
viewMode,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab
settingsTab,
navHistory
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
@@ -58,9 +60,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number {
switch (freq) {
@@ -91,32 +91,19 @@
// Track note navigation in history stack
$effect(() => {
const path = $activeNotePath;
if (navigatingFromHistory) {
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
if (path) navHistory.push(path);
});
function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction;
if (newIndex < 0 || newIndex >= noteHistory.length) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
const path = navHistory.go(direction);
if (!path) return;
readNote(path).then((content) => {
editor?.flushSave();
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
editor?.loadNote(path, content.content);
}).catch(() => {
// Note may have been deleted, ignore
});
}).catch(() => {});
}
async function handleOpenFile(filePath: string) {
@@ -493,7 +480,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{$activeNotebook?.name || 'All Notes'}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -597,7 +584,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
@@ -662,7 +649,7 @@
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
+413 -25
View File
@@ -29,16 +29,15 @@
import katex from 'katex';
import 'katex/dist/katex.min.css';
import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, EditorState } from '@tiptap/pm/state';
import { Plugin, PluginKey, EditorState, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { DOMSerializer } from '@tiptap/pm/model';
import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event';
@@ -177,9 +176,10 @@
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() },
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => editor?.chain().focus().setDetails().run() },
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => insertDetails() },
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() },
];
}
@@ -214,6 +214,7 @@
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null);
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let noteFolder = $derived(noteRelativePath ? noteRelativePath.substring(0, noteRelativePath.lastIndexOf('/')) : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common);
@@ -389,6 +390,27 @@
},
});
const PageBreak = TiptapNode.create({
name: 'pageBreak',
group: 'block',
atom: true,
parseHTML() {
return [
{ tag: 'div[data-page-break]' },
{
tag: 'div',
getAttrs: (el: HTMLElement) => {
const style = el.getAttribute('style') || '';
return style.includes('page-break-after') ? {} : false;
},
},
];
},
renderHTML() {
return ['div', { 'data-page-break': 'true', style: 'page-break-after: always; break-after: page;', class: 'page-break' }];
},
});
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
@@ -497,6 +519,30 @@
},
});
// Tab inserts a tab character in plain paragraphs/headings.
// Priority 50 < default 100, so list/task/table/codeblock extensions handle Tab first for their own nodes.
const TabIndent = Extension.create({
name: 'tabIndent',
priority: 50,
addKeyboardShortcuts() {
return {
Tab: () => {
const sel = this.editor.state.selection;
const from = sel.$from;
const node = from.node();
if (node.type.name !== 'paragraph' && node.type.name !== 'heading') return false;
// Don't intercept if inside a list or task item (their extensions handle Tab first,
// but guard here too in case of priority edge cases)
for (let d = from.depth - 1; d > 0; d--) {
const name = from.node(d).type.name;
if (name === 'listItem' || name === 'taskItem') return false;
}
return this.editor.commands.insertContent('\t');
},
};
},
});
const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect',
addGlobalAttributes() {
@@ -597,6 +643,22 @@
slashTableHover = { rows: 0, cols: 0 };
}
$effect(() => {
if (!slashMenu || slashSelectedIndex < 0) return;
slashSelectedIndex; // track
tick().then(() => {
document.querySelector('.slash-menu .slash-menu-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
$effect(() => {
if (!wikiLinkMenu || wikiLinkSelectedIndex < 0) return;
wikiLinkSelectedIndex; // track
tick().then(() => {
document.querySelector('.wiki-link-menu .wiki-link-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
function updateSlashMenu() {
const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false;
@@ -639,9 +701,11 @@
// Keep menu within viewport (account for virtual keyboard on mobile)
if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300);
const menuHeight = 300;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
slashMenu = { x, y, query, from, to };
slashSelectedIndex = 0;
@@ -761,9 +825,22 @@
if (pipeIdx >= 0) q = q.slice(0, pipeIdx);
q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!q) return wikiLinkTitlesCache;
return wikiLinkTitlesCache.filter(entry =>
entry.title.toLowerCase().includes(q)
);
// Score: 0 = exact, 1 = starts-with, 2 = word-start, 3 = contains
const scored = wikiLinkTitlesCache
.map(entry => {
const t = entry.title.toLowerCase();
let score: number;
if (t === q) score = 0;
else if (t.startsWith(q)) score = 1;
else if (t.includes(' ' + q) || t.includes('-' + q)) score = 2;
else if (t.includes(q)) score = 3;
else score = -1;
return { entry, score };
})
.filter(x => x.score >= 0)
.sort((a, b) => a.score - b.score)
.map(x => x.entry);
return scored;
});
// Set of lowercase titles that appear more than once (for disambiguation display)
@@ -1047,9 +1124,11 @@
const coords = editor.view.coordsAtPos(from);
let x = coords.left;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300);
const menuHeight = 360;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
wikiLinkMenu = { x, y, query, from };
wikiLinkSelectedIndex = 0;
}
@@ -1377,6 +1456,18 @@
});
}
async function editorNavigateHistory(direction: -1 | 1) {
const path = navHistory.go(direction);
if (!path) return;
flushSave();
try {
const content = await readNote(path);
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
} catch {}
}
/** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save).
* Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */
export function flushSave() {
@@ -1404,9 +1495,11 @@
const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false);
$readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly);
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
if ($sourceMode) {
sourceContent = stripTitleH1(content);
resetSourceHistory(sourceContent);
if (editorBody) editorBody.scrollTop = 0;
isLoadingNote = false;
} else if (editorElement && editor) {
// Editor already exists, just swap content
@@ -1415,8 +1508,19 @@
editor.commands.setContent(html);
// Clear undo/redo history so it doesn't bleed across notes
clearEditorHistory();
// Clear loading flag after setContent updates have fired
tick().then(() => { isLoadingNote = false; });
// Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
tick().then(() => {
if (editorBody) editorBody.scrollTop = 0;
// Explicitly reset ProseMirror selection to start so TipTap's focus()
// (triggered by checkbox clicks etc.) doesn't scroll to the old note's cursor position.
if (editor) {
const tr = editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc));
// No tr.scrollIntoView() — must not trigger any scroll
editor.view.dispatch(tr);
}
requestAnimationFrame(() => { if (editorBody) editorBody.scrollTop = 0; });
isLoadingNote = false;
});
} else {
// Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init.
@@ -1571,6 +1675,8 @@
return serializeListItem(node);
case 'horizontalRule':
return '---\n';
case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n';
case 'table': {
// Preserve tables as raw HTML
const tempDiv = document.createElement('div');
@@ -2291,12 +2397,62 @@
PdfEmbed,
MathBlock,
MathInline,
Details.configure({ persist: false, HTMLAttributes: { class: 'editor-details' } }),
PageBreak,
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
DetailsSummary,
DetailsContent,
Extension.create({
name: 'collapsibleKeymap',
addProseMirrorPlugins() {
return [new Plugin({
key: new PluginKey('collapsibleKeymap'),
props: {
handleDOMEvents: {
keydown(view, event) {
const isTab = event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
const isEnter = event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTab && !isEnter) return false;
const { schema, selection } = view.state;
const from = selection.$from;
let summaryDepth = -1;
for (let d = from.depth; d >= 0; d--) {
if (from.node(d).type === schema.nodes.detailsSummary) { summaryDepth = d; break; }
}
if (summaryDepth === -1) return false;
event.preventDefault();
const detailsDepth = summaryDepth - 1;
const detailsNode = from.node(detailsDepth);
let detailsContentPos: number | null = null;
let pos = from.start(detailsDepth);
for (let i = 0; i < detailsNode.childCount; i++) {
const child = detailsNode.child(i);
if (child.type === schema.nodes.detailsContent) { detailsContentPos = pos + 1; break; }
pos += child.nodeSize;
}
if (detailsContentPos === null) return true;
// Open the section if it is closed
const domPos = view.domAtPos(from.pos);
let domNode = domPos.node as HTMLElement;
if (domNode.nodeType === 3) domNode = domNode.parentElement as HTMLElement;
const detailsEl = domNode?.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open state into document + move cursor (single transaction)
const detailsPos = from.before(detailsDepth);
view.dispatch(view.state.tr
.setNodeMarkup(detailsPos, undefined, { open: true })
.setSelection(TextSelection.create(view.state.doc, detailsContentPos))
);
return true;
}
}
}
})];
}
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands,
MoveLineShortcuts,
TabIndent,
NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
],
@@ -2304,9 +2460,11 @@
editorProps: {
attributes: { class: 'editor-content' },
handleDOMEvents: {
// Prevent details toggle button from stealing focus, which causes scroll-to-top.
// Also pre-focus the editor with preventScroll so TipTap's focus command
// sees hasFocus()=true and skips its scrolling view.focus() call.
// Prevent focus-caused scroll jumps when clicking details toggle buttons.
// Pre-focusing with preventScroll means TipTap's focus() call sees
// hasFocus()=true and skips its scrolling view.focus() call.
// For task checkboxes: lock scroll on mousedown (before any dispatch fires)
// so that any synchronous or async scroll caused by the toggle is reverted.
mousedown: (view, event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-type="details"] > button')) {
@@ -2315,6 +2473,16 @@
(view.dom as HTMLElement).focus({ preventScroll: true });
}
}
if (target.closest('[data-type="taskItem"] input[type="checkbox"]')) {
const editorBody = target.closest('.editor-body') as HTMLElement | null;
if (editorBody) {
const savedScroll = editorBody.scrollTop;
const restore = () => { editorBody!.scrollTop = savedScroll; };
editorBody.addEventListener('scroll', restore);
// Remove after 200ms — covers synchronous, rAF, and setTimeout-based scrolls
setTimeout(() => editorBody!.removeEventListener('scroll', restore), 200);
}
}
},
// Prevent native text drag — it causes copy-instead-of-move in Tauri's webview.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead.
@@ -2756,8 +2924,31 @@
closeTextContextMenu();
}
function openDetailsEl(el: HTMLElement) {
if (!el.classList.contains('is-open')) {
el.classList.add('is-open');
(el.querySelector('[data-type="detailsContent"]') as HTMLElement | null)
?.dispatchEvent(new Event('toggleDetailsContent'));
}
}
function insertDetails() {
if (!editor) return;
editor.chain().focus().setDetails().run();
requestAnimationFrame(() => {
if (!editor) return;
const domPos = editor.view.domAtPos(editor.state.selection.from);
let node = domPos.node as HTMLElement;
if (node.nodeType === 3) node = node.parentElement as HTMLElement;
const detailsEl = node.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open: true into the document so it saves with the note
editor.chain().updateAttributes('details', { open: true }).run();
});
}
function ctxDetails() {
editor?.chain().focus().setDetails().run();
insertDetails();
closeTextContextMenu();
}
@@ -3429,6 +3620,16 @@
</div>
{#if !isMobile}
<div class="toolbar-actions">
{#if $canGoBack || $canGoForward}
<div class="nav-history-btns">
<button class="nav-history-btn" disabled={!$canGoBack} onclick={() => editorNavigateHistory(-1)} title="Back (Alt+←)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="nav-history-btn" disabled={!$canGoForward} onclick={() => editorNavigateHistory(1)} title="Forward (Alt+→)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
{/if}
{#if $editorDirty}
<span class="save-indicator">Unsaved</span>
{/if}
@@ -3545,6 +3746,31 @@
{/if}
</div>
{#if !$focusMode}
<div class="note-meta-bar">
<span class="note-folder" class:unfiled={!noteFolder}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{#if noteFolder}
{#each noteFolder.split('/') as segment, i}
{#if i > 0}<span class="path-sep"></span>{/if}{segment}
{/each}
{:else}
Unfiled Notes
{/if}
</span>
{#if $activeNote.meta.tags?.length > 0}
<span class="meta-divider">·</span>
{/if}
{#if $activeNote.meta.tags?.length > 0}
<span class="note-tags">
{#each $activeNote.meta.tags as tag}
<span class="note-tag">#{tag}</span>
{/each}
</span>
{/if}
</div>
{/if}
<div class="editor-body-wrapper">
{#if noteSearchOpen}
<div class="note-search-bar">
@@ -3970,7 +4196,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
Quote
</button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setDetails().run(); }}>
<button onclick={() => { insertDropdown = false; insertDetails(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section
</button>
@@ -4079,7 +4305,7 @@
</button>
<!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => editor?.chain().focus().setDetails().run()} title="Collapsible Section">
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title="Collapsible Section">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
</button>
@@ -4618,8 +4844,8 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span>
{#if wikiLinkDuplicateTitles.has(entry.title.toLowerCase())}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span>
{#if wikiLinkFolderPath(entry)}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry)}</span>
{/if}
</span>
</button>
@@ -4908,6 +5134,40 @@
flex-shrink: 0;
}
.nav-history-btns {
display: flex;
align-items: center;
gap: 2px;
margin-right: auto;
flex-shrink: 0;
}
.nav-history-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-tertiary);
border-radius: 5px;
cursor: pointer;
padding: 0;
transition: color 0.15s, background 0.15s;
}
.nav-history-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.nav-history-btn:disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.editor-title {
flex: 1;
}
@@ -4929,6 +5189,57 @@
color: var(--text-tertiary);
}
.note-meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
padding: 0 20px 10px;
flex-shrink: 0;
}
.note-folder {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-tertiary);
letter-spacing: 0.01em;
}
.note-folder.unfiled {
opacity: 0.6;
font-style: italic;
}
.path-sep {
font-size: 10px;
opacity: 0.5;
}
.meta-divider {
font-size: 11px;
color: var(--text-tertiary);
opacity: 0.4;
user-select: none;
}
.note-tags {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.note-tag {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 1px 7px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.toolbar-actions {
display: flex;
align-items: center;
@@ -5198,11 +5509,25 @@
.editor-body {
flex: 1;
overflow-y: auto;
overflow-anchor: none;
padding: 8px 20px;
min-width: 0;
position: relative;
}
.editor-body::-webkit-scrollbar {
width: 8px;
}
.editor-body::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.editor-body::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.editor-body:has(.source-editor) {
overflow: hidden;
}
@@ -5738,6 +6063,12 @@
transform: rotate(90deg);
}
@starting-style {
:global(.tiptap-wrapper .tiptap [data-type="details"].is-open > button::after) {
transform: rotate(0deg);
}
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary) {
padding: 10px 14px 10px 32px;
font-weight: 600;
@@ -5864,7 +6195,10 @@
min-width: 0;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div) {
/* Strike through only the direct paragraph content of a checked task item.
Using > p instead of > div prevents text-decoration from bleeding into
nested task lists, since CSS text-decoration cannot be cancelled by descendants. */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p) {
text-decoration: line-through;
color: var(--text-tertiary);
}
@@ -6111,10 +6445,34 @@
padding-left: 2px;
}
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] .is-empty::before) {
/* Suppress placeholder on task list / details containers — overlaps with checkbox / toggle button */
:global(.tiptap-wrapper .tiptap > ul[data-type="taskList"].is-empty::before),
:global(.tiptap-wrapper .tiptap > [data-type="details"].is-empty::before) {
content: none;
}
/* Show placeholder on the paragraph inside task item content div */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li > div > p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
/* Show placeholder on paragraphs inside collapsible section summary and content */
:global(.tiptap-wrapper .tiptap [data-type="detailsSummary"] p.is-empty::before),
:global(.tiptap-wrapper .tiptap [data-type="detailsContent"] p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
.link-context-overlay {
position: fixed;
inset: 0;
@@ -6575,6 +6933,36 @@
border-top: 1px solid var(--border);
margin: 0;
}
:global(.tiptap .page-break) {
position: relative;
margin: 12px 0;
height: 20px;
border: none;
pointer-events: none;
user-select: none;
}
:global(.tiptap .page-break::before) {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 0;
border-top: 2px dashed var(--text-tertiary, #aaa);
opacity: 0.5;
}
:global(.tiptap .page-break::after) {
content: 'Page Break';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-primary, #fff);
padding: 0 8px;
font-size: 11px;
color: var(--text-tertiary, #aaa);
white-space: nowrap;
}
:global(.tiptap .pdf-embed-mobile) {
margin: 8px 0;
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
+25 -1
View File
@@ -38,10 +38,11 @@
import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: {
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -230,6 +231,15 @@
if (listContainer) listContainer.scrollTop = 0;
});
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver
$effect(() => {
if (!listContainer) return;
@@ -369,6 +379,7 @@
noteCache.clear();
await refresh();
await selectNote(entry);
onNoteCreated();
} catch (e) {
console.error('Failed to create note:', e);
}
@@ -1412,6 +1423,19 @@
padding: 4px;
}
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item {
display: block;
width: 100%;
+1 -1
View File
@@ -4,7 +4,7 @@
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent);
+1
View File
@@ -50,6 +50,7 @@
}
}
export async function refresh() {
try {
if (isMobile) {
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary);
}
.switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
+30 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store";
import { writable, derived, get } from "svelte/store";
import type {
AppConfig,
NoteEntry,
@@ -106,6 +106,35 @@ export async function checkForUpdateMobile() {
}
}
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived
export const sortedNotes = derived(
[notes, sortMode, viewMode],
+1 -2
View File
@@ -2,8 +2,7 @@
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, readNote, getInstallType } from '$lib/api';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store';
let { children } = $props();