Compare commits

...
18 Commits
Author SHA1 Message Date
Yuri Karamian 4a1045b41e Preserve collapsible code blocks (#138) 2026-08-03 03:26:06 +02:00
Yuri Karamian c305b5f8dd Add Android camera image capture (#131) 2026-08-03 03:13:04 +02:00
Yuri Karamian b5962af466 Add collapsible section shortcut (#129) 2026-08-03 02:56:42 +02:00
Yuri Karamian 13e83b6689 Restore note scroll positions (#127) 2026-08-03 02:43:40 +02:00
Yuri Karamian 432e4f472a Keep local tests out of repository 2026-08-03 02:31:55 +02:00
Yuri Karamian 8731ca4ec1 Support mixed nested lists (#39) 2026-08-03 02:26:39 +02:00
Yuri Karamian 6b165059cc Refine note info path and history 2026-08-03 02:03:58 +02:00
Yuri Karamian 213797a38e Fix mobile outline navigation (#109) 2026-08-03 01:53:29 +02:00
Yuri Karamian fc3e388b80 Refine custom icon size guidance 2026-08-03 01:53:25 +02:00
Yuri Karamian 1c0359e540 Add custom icon size guidance 2026-08-03 01:33:38 +02:00
Yuri Karamian da859e581b Copy active note path from Info panel 2026-08-03 01:16:30 +02:00
Yuri Karamian 763e673738 Show vault location in Info panel 2026-08-03 01:12:50 +02:00
Yuri Karamian 550d3ab3e9 Add per-notebook icons and sync 2026-08-03 01:05:07 +02:00
Yuri Karamian e62404d4c0 Fix dragging selected notes to notebooks 2026-08-03 00:27:18 +02:00
Yuri Karamian 54e051ba02 Add configurable startup view 2026-08-03 00:12:55 +02:00
Yuri Karamian 344728dbe8 Fix macOS traffic lights after window state changes
Reapply the native traffic-light alignment after resize, focus, scale, and theme events so AppKit window-state transitions cannot leave the controls at their default position.
2026-08-02 23:53:26 +02:00
Yuri Karamian 818e9ad82a Merge branch 'nix-build-fix' into 'main'
fix(nix): Update flake.lock and increment fetcher version

Closes #230

See merge request ArkHost/HelixNotes!1
2026-08-02 21:41:20 +00:00
jervw 4931ee3fab fix(nix): Update flake.lock and increment fetcher version 2026-08-02 21:41:20 +00:00
23 changed files with 1047 additions and 268 deletions
+3
View File
@@ -13,6 +13,9 @@ bun.lockb
/.svelte-kit
/build
# Local tests
/tests/
# Nix
/result
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"lastModified": 1785571196,
"narHash": "sha256-KoTsyMQqnXQZq8deCEnu4QkyldkwH/bpMMhUcfMdGIw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "github"
},
"original": {
+3 -3
View File
@@ -26,13 +26,13 @@
inherit pname version;
src = ./.;
cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
cargoHash = "sha256-P0iXImWYKYPxVj7q2JSuDmd8JvjcKW4DzFUzXeUQZPk=C";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
fetcherVersion = 4;
hash = "sha256-odUNKO2D50DG+VpuoTEo5FLMYy5pQSHQBtfHJrhJt78=";
};
nativeBuildInputs = with pkgs; [
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+19
View File
@@ -1231,6 +1231,23 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
// ── Clipboard ──
/// Copy text to the system clipboard through the native backend.
#[cfg(desktop)]
#[tauri::command]
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| format!("Failed to copy text: {}", e))
}
#[cfg(mobile)]
#[tauri::command]
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Text clipboard copy is only supported on desktop".to_string())
}
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(desktop)]
@@ -1385,6 +1402,7 @@ pub fn set_general_settings(
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
startup_view: StartupView,
restore_last_session: bool,
show_all_notes: bool,
show_quick_access: bool,
@@ -1395,6 +1413,7 @@ pub fn set_general_settings(
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.startup_view = startup_view;
config.restore_last_session = restore_last_session;
config.show_all_notes = show_all_notes;
config.show_quick_access = show_quick_access;
+26 -9
View File
@@ -59,16 +59,9 @@ pub fn run() {
.on_page_load(|webview, payload| {
#[cfg(target_os = "macos")]
if webview.label() == "main"
&& matches!(
payload.event(),
tauri::webview::PageLoadEvent::Finished
)
&& matches!(payload.event(), tauri::webview::PageLoadEvent::Finished)
{
let window = webview.window();
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
queue_macos_traffic_light_fix(&webview.window());
}
#[cfg(not(target_os = "macos"))]
let _ = (webview, payload);
@@ -177,6 +170,7 @@ pub fn run() {
commands::empty_trash,
commands::load_vault_state,
commands::save_vault_state,
commands::copy_text_to_clipboard,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image,
@@ -321,6 +315,21 @@ pub fn run() {
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| {
#[cfg(target_os = "macos")]
if window.label() == "main"
&& matches!(
event,
tauri::WindowEvent::Resized(_)
| tauri::WindowEvent::Focused(true)
| tauri::WindowEvent::ScaleFactorChanged { .. }
| tauri::WindowEvent::ThemeChanged(_)
)
{
// AppKit can restore the default button Y position after window-state
// changes. Queue this pass so it runs after AppKit finishes its layout.
queue_macos_traffic_light_fix(window);
}
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
@@ -410,6 +419,14 @@ fn percent_decode(input: &str) -> String {
String::from_utf8_lossy(&output).to_string()
}
#[cfg(target_os = "macos")]
fn queue_macos_traffic_light_fix(window: &tauri::Window) {
let window_on_main = window.clone();
let _ = window.run_on_main_thread(move || {
fix_macos_traffic_lights(&window_on_main);
});
}
#[cfg(target_os = "macos")]
fn fix_macos_traffic_lights(window: &tauri::Window) {
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
+32 -7
View File
@@ -6,9 +6,9 @@
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
// Synced set: every `*.md` in the vault tree, `.helixnotes/attachments/`, and
// `.helixnotes/notebook_icons.json`. Search indexes, trash, history, other metadata,
// and the manifest itself remain local-only.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
@@ -119,12 +119,12 @@ struct LocalFile {
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
/// The synced set: `*.md` anywhere outside `.helixnotes/`, everything under
/// `.helixnotes/attachments/`, and the notebook icon mapping. Applied to BOTH local
/// and remote so pointing at a folder with unrelated files never imports them.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
rel.starts_with(".helixnotes/attachments/") || rel == ".helixnotes/notebook_icons.json"
} else {
rel.ends_with(".md")
}
@@ -724,3 +724,28 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
);
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::is_synced_relpath;
#[test]
fn syncs_notebook_icon_mapping_and_assets_only() {
for path in [
"Notes/plan.md",
".helixnotes/attachments/notebook-icon.png",
".helixnotes/notebook_icons.json",
] {
assert!(is_synced_relpath(path), "expected {path} to be synced");
}
for path in [
"Notes/image.png",
".helixnotes/sync_state.json",
".helixnotes/notebook_icons.json.bak",
".helixnotes/attachments-old/icon.png",
] {
assert!(!is_synced_relpath(path), "expected {path} to stay local");
}
}
}
+39
View File
@@ -96,6 +96,17 @@ pub struct CustomTheme {
pub colors: CustomThemeColors,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StartupView {
Daily,
QuickAccess,
Tasks,
#[default]
#[serde(other)]
All,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub vaults: Vec<VaultConfig>,
@@ -192,6 +203,8 @@ pub struct AppConfig {
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
#[serde(default)]
pub startup_view: StartupView,
#[serde(default)]
pub restore_last_session: bool,
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
#[serde(default)]
@@ -305,6 +318,7 @@ impl Default for AppConfig {
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
startup_view: StartupView::All,
restore_last_session: false,
sync_provider: None,
webdav_url: None,
@@ -469,3 +483,28 @@ pub struct TaskItem {
pub due: Option<String>,
pub priority: Option<String>,
}
#[cfg(test)]
mod startup_view_tests {
use super::StartupView;
#[test]
fn serializes_supported_startup_views() {
for (view, expected) in [
(StartupView::All, "\"all\""),
(StartupView::QuickAccess, "\"quickaccess\""),
(StartupView::Tasks, "\"tasks\""),
(StartupView::Daily, "\"daily\""),
] {
assert_eq!(serde_json::to_string(&view).unwrap(), expected);
}
}
#[test]
fn unknown_startup_view_falls_back_to_all_notes() {
assert_eq!(
serde_json::from_str::<StartupView>("\"future-view\"").unwrap(),
StartupView::All
);
}
}
+27
View File
@@ -1237,3 +1237,30 @@ pub fn sanitize_filename(name: &str) -> String {
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::{helixnotes_dir, load_notebook_icons, set_notebook_icon};
use std::fs;
use uuid::Uuid;
#[test]
fn persists_and_removes_builtin_notebook_icons() {
let vault =
std::env::temp_dir().join(format!("helixnotes-notebook-icon-test-{}", Uuid::new_v4()));
let vault_path = vault.to_string_lossy();
fs::create_dir_all(helixnotes_dir(&vault_path)).unwrap();
set_notebook_icon(&vault_path, "Projects", Some("builtin:briefcase")).unwrap();
let icons = load_notebook_icons(&vault_path).unwrap();
assert_eq!(
icons.get("Projects").map(String::as_str),
Some("builtin:briefcase")
);
set_notebook_icon(&vault_path, "Projects", None).unwrap();
assert!(load_notebook_icons(&vault_path).unwrap().is_empty());
fs::remove_dir_all(vault).unwrap();
}
}
+7
View File
@@ -16,6 +16,7 @@ import type {
VersionEntry,
TaskItem,
ExternalVaultResult,
StartupView,
} from "./types";
export async function openVault(path: string): Promise<void> {
@@ -216,6 +217,10 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
return invoke("save_vault_state", { vaultState });
}
export async function copyTextToClipboard(text: string): Promise<void> {
return invoke("copy_text_to_clipboard", { text });
}
export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image");
}
@@ -264,6 +269,7 @@ export async function setGeneralSettings(
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
startupView: StartupView,
restoreLastSession: boolean,
showAllNotes: boolean,
showQuickAccess: boolean,
@@ -289,6 +295,7 @@ export async function setGeneralSettings(
closeToTray,
enableWikiLinks,
showNoteDates,
startupView,
restoreLastSession,
showAllNotes,
showQuickAccess,
+57 -27
View File
@@ -65,8 +65,10 @@
import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { normalizeStartupView, resolveStartupTarget } from '$lib/utils/startup-view';
import { get } from 'svelte/store';
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
import type { StartupTarget } from '$lib/utils/startup-view';
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
for (const nb of list) {
@@ -81,6 +83,38 @@
let noteList: NoteList;
let editor: Editor;
let unlistenFileChange: (() => void) | null = null;
async function applyStartupTarget(target: StartupTarget): Promise<boolean> {
if (target.mode === 'notebook') {
const vault = $appConfig?.active_vault;
const notebook = target.notebookPath === '' && vault
? { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount }
: findNotebookByPath($notebooks, target.notebookPath);
if (!notebook) return false;
const changed = $viewMode !== 'notebook' || $activeNotebook?.relative_path !== notebook.relative_path;
$viewMode = 'notebook';
$activeNotebook = notebook;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
if (target.mode === 'tag') {
const changed = $viewMode !== 'tag' || $activeTag !== target.tag;
$viewMode = 'tag';
$activeTag = target.tag;
$activeNotebook = null;
if (changed) await noteList?.refresh();
return true;
}
const changed = $viewMode !== target.mode || $activeNotebook !== null || $activeTag !== null;
$viewMode = target.mode;
$activeNotebook = null;
$activeTag = null;
if (changed) await noteList?.refresh();
return true;
}
let unlistenOpenFile: (() => void) | null = null;
// Mobile editor header helpers
@@ -599,9 +633,11 @@
lastTag = state.last_tag ?? null;
} catch (_) {}
const restoreLastSession = $appConfig?.restore_last_session === true;
// On mobile, prefetch last-opened note so first tap is instant
let prefetchPromise: Promise<any> | null = null;
if (isMobile && lastNotePath) {
if (isMobile && restoreLastSession && lastNotePath) {
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
@@ -637,32 +673,26 @@
}, 3000);
}
// Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) {
const vault = $appConfig?.active_vault;
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
await noteList?.refresh();
} else if (lastViewMode === 'notebook' && lastNotebook) {
const nb = findNotebookByPath($notebooks, lastNotebook);
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
} else if (lastViewMode === 'tag' && lastTag) {
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
} else if (lastViewMode === 'quickaccess') {
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
}
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
if (!isMobile && lastNotePath) {
try {
const content = await readNote(lastNotePath);
$activeNote = content;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, content.content);
} catch (_) {}
}
const startupTarget = resolveStartupTarget({
startupView: $appConfig?.startup_view,
restoreLastSession,
lastViewMode,
lastNotebook,
lastTag
});
if (!(await applyStartupTarget(startupTarget))) {
await applyStartupTarget({ mode: normalizeStartupView($appConfig?.startup_view) });
}
// Reopen the last note only when session restoration is enabled.
if (restoreLastSession && !isMobile && lastNotePath) {
try {
const content = await readNote(lastNotePath);
$activeNote = content;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, content.content);
} catch (_) {}
}
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
+258 -138
View File
@@ -37,7 +37,7 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs';
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard } from '$lib/api';
import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd, writeBytesTo, copyPngToClipboard, copyTextToClipboard } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward, viewerNote, notebooks, outlineWidth } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
@@ -48,6 +48,7 @@
import { WrapSelectedText } from '$lib/editor/extensions/wrapSelectedText';
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { relativePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
@@ -70,12 +71,25 @@
const LARGE_DOC_CHARS = 100_000;
let isLargeDoc = $state(false);
let editor: Editor | null = null;
const MixedListShortcuts = Extension.create({
name: 'mixedListShortcuts',
priority: 1000,
addKeyboardShortcuts() {
return {
'Mod-Shift-8': () => toggleBulletList(),
'Mod-Shift-9': () => toggleTaskList(),
};
},
});
let editorReady = $state(false);
let sourceContent = $state('');
let sourceHistory: Array<{ content: string; cursor: number }> = [];
let sourceHistoryIndex = -1;
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
let loadedPath = '';
type NoteScrollPosition = { rich: number; source: number };
const MAX_NOTE_SCROLL_POSITIONS = 200;
const noteScrollPositions = new Map<string, NoteScrollPosition>();
let pendingContent = $state<string | null>(null);
let ignoreNextUpdate = false;
let isLoadingNote = false;
@@ -301,11 +315,19 @@
$outlineWidth = Math.max(160, Math.min(500, $outlineWidth - delta));
}
function scrollToHeading(pos: number) {
async function scrollToHeading(pos: number) {
if (isMobile) {
showOutline = false;
await tick();
}
if (!editor) return;
editor.commands.setTextSelection(pos + 1);
editor.commands.scrollIntoView();
editor.view.focus();
if (isMobile) {
const heading = editor.view.nodeDOM(pos);
if (heading instanceof HTMLElement) heading.scrollIntoView({ block: 'start' });
}
}
// Version history
@@ -323,6 +345,15 @@
let infoToggleBtnEl = $state<HTMLElement | null>(null);
let wordCount = $state(0);
let charCount = $state(0);
let infoPathCopyState = $state<'idle' | 'copied' | 'error'>('idle');
let infoPathCopyTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
$activeNotePath;
infoPathCopyState = 'idle';
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
infoPathCopyTimer = null;
});
// In-note search
let noteSearchOpen = $state(false);
@@ -364,9 +395,9 @@
{ label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: 'Heading 2', aliases: ['h2', 'heading2', 'subtitle'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: 'Heading 3', aliases: ['h3', 'heading3'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 01-2 2m2 0a2 2 0 01-2 2c-1.5 0-3.5 0-3.5-1.5"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run() },
{ label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>', action: () => editor?.chain().focus().toggleBulletList().run() },
{ label: 'Bullet List', aliases: ['ul', 'unordered', 'bullets', 'list'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor"/><circle cx="3" cy="12" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>', action: toggleBulletList },
{ label: 'Numbered List', aliases: ['ol', 'ordered', 'number'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="9" font-size="8" fill="currentColor" stroke="none">1</text><text x="1" y="15" font-size="8" fill="currentColor" stroke="none">2</text><text x="1" y="21" font-size="8" fill="currentColor" stroke="none">3</text></svg>', action: () => editor?.chain().focus().toggleOrderedList().run() },
{ 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: '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: toggleTaskList },
{ 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: 'Secret', aliases: ['secret', 'encrypt', 'password', 'private'], 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="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>', action: () => openSecretInsert() },
{ 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() },
@@ -3058,19 +3089,27 @@
const scheduleCounts = debounce(updateCounts, 250);
async function toggleInfo() {
if (!showInfo && $activeNote) {
historyLoading = true;
try {
historyVersions = await getNoteVersions($activeNote.meta.id);
} catch {
historyVersions = [];
}
historyLoading = false;
}
function toggleInfo() {
showInfo = !showInfo;
}
async function copyActiveNotePath() {
const path = $activeNotePath;
if (!path) return;
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
try {
await copyTextToClipboard(path);
infoPathCopyState = 'copied';
} catch (e) {
console.error('Failed to copy note path:', e);
infoPathCopyState = 'error';
}
infoPathCopyTimer = setTimeout(() => {
infoPathCopyState = 'idle';
infoPathCopyTimer = null;
}, 1600);
}
async function toggleHistory() {
showHistory = !showHistory;
historyPreview = null;
@@ -3200,7 +3239,34 @@
$editorDirty = false;
}
function rememberLoadedNoteScroll() {
if (!loadedPath) return;
const previous = noteScrollPositions.get(loadedPath) ?? { rich: 0, source: 0 };
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
const position = $sourceMode
? { ...previous, source: sourceElement?.scrollTop ?? previous.source }
: { ...previous, rich: editorBody?.scrollTop ?? previous.rich };
// Refresh insertion order so the cap evicts the least recently viewed note.
noteScrollPositions.delete(loadedPath);
noteScrollPositions.set(loadedPath, position);
if (noteScrollPositions.size > MAX_NOTE_SCROLL_POSITIONS) {
const oldestPath = noteScrollPositions.keys().next().value;
if (oldestPath) noteScrollPositions.delete(oldestPath);
}
}
function restoreNoteScroll(surface: HTMLElement, scrollTop: number, path: string) {
const apply = () => {
if (loadedPath === path && surface.isConnected) surface.scrollTop = scrollTop;
};
apply();
requestAnimationFrame(apply);
}
export function loadNote(path: string, content: string, taskTarget?: TaskRecord) {
rememberLoadedNoteScroll();
const scrollPosition = taskTarget ? undefined : noteScrollPositions.get(path);
clearTaskReveal();
const revealRequest = ++taskRevealRequest;
const revealTarget = taskTarget ? resolveTaskTarget(taskTarget, content) : null;
@@ -3220,6 +3286,9 @@
sourceContent = stripTitleH1(content);
resetSourceHistory(sourceContent);
if (editorBody) editorBody.scrollTop = 0;
tick().then(() => {
if (sourceElement) restoreNoteScroll(sourceElement, scrollPosition?.source ?? 0, path);
});
isLoadingNote = false;
updateCounts();
} else if (editorElement && editor) {
@@ -3232,9 +3301,10 @@
const text = editor.state.doc.textContent;
wordCount = countWords(text);
charCount = text.replace(/\s/g, '').length;
// Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
// Restore scroll and reset the cursor after all ProseMirror/Svelte DOM updates settle.
tick().then(() => {
if (editorBody) editorBody.scrollTop = 0;
if (path !== loadedPath) return;
if (editorBody) restoreNoteScroll(editorBody, scrollPosition?.rich ?? 0, path);
// 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) {
@@ -3242,15 +3312,14 @@
// 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.
pendingContent = content;
isLoadingNote = false;
}
// Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init.
pendingContent = content;
isLoadingNote = false;
}
if (!isMobile && showOutline) scheduleOutline();
scheduleTaskReveal(path, revealTarget, revealRequest);
}
@@ -3493,11 +3562,12 @@
return `$$\n${tex}\n$$\n`;
}
case 'details': {
// Preserve details as raw HTML
// Markdown HTML blocks end at a blank line, including one inside <pre><code>.
// Keep the details element on one source line; HTML parsing restores each encoded newline.
const detDiv = document.createElement('div');
const detFrag = DOMSerializer.fromSchema(editor!.schema).serializeNode(node);
detDiv.appendChild(detFrag);
return detDiv.innerHTML + '\n';
return detDiv.innerHTML.replace(/\n/g, '&#10;') + '\n';
}
case 'image': {
const src = stripAssetSrc(node.attrs.src || '');
@@ -4309,6 +4379,7 @@
element: editorElement,
editable: !$readOnly,
extensions: [
MixedListShortcuts,
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({
includeChildren: true,
@@ -4355,6 +4426,16 @@
props: {
handleDOMEvents: {
keydown(view, event) {
const isInsertDetails = (event.ctrlKey || event.metaKey)
&& !event.shiftKey
&& !event.altKey
&& event.code === 'Period';
if (isInsertDetails) {
if (get(readOnly)) return false;
event.preventDefault();
insertDetails();
return true;
}
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;
@@ -5116,8 +5197,52 @@
closeTextContextMenu();
}
function handleMixedListToggle(targetListName: MixedListName): boolean {
if (!editor) return false;
const { selection, schema } = editor.state;
const { $from: fromResolved, $to: toResolved } = selection;
let listDepth = -1;
for (let depth = fromResolved.depth; depth > 0; depth--) {
const nodeName = fromResolved.node(depth).type.name;
// An ordered list cannot contain task items. Keep it ordered instead of lifting its item.
if (nodeName === 'orderedList') return targetListName === 'taskList';
if (nodeName === 'bulletList' || nodeName === 'taskList') {
listDepth = depth;
break;
}
}
if (listDepth < 0 || toResolved.depth < listDepth) return false;
const listNode = fromResolved.node(listDepth);
if (listNode.type.name === targetListName || toResolved.node(listDepth) !== listNode) return false;
const convertedList = convertListNode(schema, listNode, targetListName);
if (!convertedList) return false;
const listPos = fromResolved.before(listDepth);
const transaction = editor.state.tr.replaceWith(
listPos,
listPos + listNode.nodeSize,
convertedList
);
transaction.setSelection(TextSelection.create(transaction.doc, selection.from, selection.to));
editor.view.dispatch(transaction.scrollIntoView());
editor.view.focus();
return true;
}
function toggleBulletList(): boolean {
if (!editor) return false;
return handleMixedListToggle('bulletList') || editor.chain().focus().toggleBulletList().run();
}
function toggleTaskList(): boolean {
if (!editor) return false;
return handleMixedListToggle('taskList') || editor.chain().focus().toggleTaskList().run();
}
function ctxBulletList() {
editor?.chain().focus().toggleBulletList().run();
toggleBulletList();
closeTextContextMenu();
}
@@ -5127,7 +5252,7 @@
}
function ctxTaskList() {
editor?.chain().focus().toggleTaskList().run();
toggleTaskList();
closeTextContextMenu();
}
@@ -5585,6 +5710,13 @@
}
}
function handleImageInput(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) insertImage(file);
input.value = '';
}
async function insertPdf(file: File) {
try {
const buffer = await file.arrayBuffer();
@@ -5798,6 +5930,7 @@
clearTaskReveal();
destroyEditor();
unlistenFileChange?.();
if (infoPathCopyTimer) clearTimeout(infoPathCopyTimer);
});
</script>
@@ -6393,37 +6526,35 @@
</span>
</div>
{/if}
</div>
<div class="info-section info-section-versions">
<div class="info-section-header">
<div class="info-section-label">Snapshots</div>
<button class="info-snapshot-btn" onclick={handleCreateVersion} title="Save snapshot">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Save now
</button>
</div>
{#if historyLoading}
<div class="info-empty">Loading...</div>
{:else if historyVersions.length === 0}
<div class="info-empty">No snapshots yet. They're created automatically as you edit.</div>
{:else}
<div class="info-versions-list">
{#each historyVersions as v}
<button
class="info-version-item"
class:active={historySelected?.timestamp === v.timestamp}
onclick={() => { previewVersion(v); showHistory = true; showInfo = false; }}
>
<span class="info-version-date">{formatVersionDate(v.timestamp)}</span>
<span class="info-version-size">{formatVersionSize(v.size)}</span>
</button>
{/each}
{#if !isMobile && $activeNotePath}
<div class="info-note-path">
<div class="info-note-path-header">
<span class="info-key">Path on disk</span>
<button
type="button"
class="info-copy-path-btn"
class:copied={infoPathCopyState === 'copied'}
class:error={infoPathCopyState === 'error'}
onclick={copyActiveNotePath}
aria-live="polite"
title="Copy note path"
>
{#if infoPathCopyState === 'copied'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 4 4L19 6" /></svg>
Copied
{:else if infoPathCopyState === 'error'}
Copy failed
{:else}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3" /></svg>
Copy
{/if}
</button>
</div>
<code class="info-note-path-value" title={$activeNotePath}>{$activeNotePath}</code>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -6447,6 +6578,12 @@
<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="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 00-2.828 0L6 21"/></svg>
Image
</button>
{#if isAndroid}
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-camera-input')?.click(); }}>
<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="M14.5 4h-5L7 7H4a2 2 0 00-2 2v9a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
Take Photo
</button>
{/if}
<button onclick={() => { insertDropdown = false; document.querySelector<HTMLInputElement>('#insert-file-input')?.click(); }}>
<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="M6 22a2 2 0 01-2-2V4a2 2 0 012-2h8a2.4 2.4 0 011.704.706l3.588 3.588A2.4 2.4 0 0120 8v12a2 2 0 01-2 2z"/><path d="M14 2v5a1 1 0 001 1h5"/></svg>
File
@@ -6527,13 +6664,13 @@
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List">
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title="Bullet List">
<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 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Numbered List">
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List">
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title="Task List">
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
</button>
@@ -6717,13 +6854,13 @@
<div class="fmt-sep"></div>
<!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title={`Bullet List (${modKey}+Shift+8)`}>
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={toggleBulletList} title={`Bullet List (${modKey}+Shift+8)`}>
<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 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title={`Ordered List (${modKey}+Shift+7)`}>
<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="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 00-2.6-1.02"/></svg>
</button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title={`Task List (${modKey}+Shift+9)`}>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={toggleTaskList} title={`Task List (${modKey}+Shift+9)`}>
<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="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/></svg>
</button>
@@ -6753,7 +6890,7 @@
</button>
<!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title="Collapsible Section">
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title={`Collapsible Section (${modKey}+.)`}>
<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>
@@ -6925,11 +7062,10 @@
{/if}
<!-- Hidden file inputs for Insert dropdown -->
<input type="file" id="insert-image-input" accept="image/*" style="display:none" onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) insertImage(file);
(e.target as HTMLInputElement).value = '';
}} />
<input type="file" id="insert-image-input" accept="image/*" style="display:none" onchange={handleImageInput} />
{#if isAndroid}
<input type="file" id="insert-camera-input" accept="image/*" capture="environment" style="display:none" onchange={handleImageInput} />
{/if}
<input type="file" id="insert-file-input" style="display:none" onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
@@ -11394,13 +11530,6 @@
flex: 1;
}
.info-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.info-section-label {
font-size: 11px;
font-weight: 600;
@@ -11410,28 +11539,6 @@
margin-bottom: 8px;
}
.info-section-header .info-section-label {
margin-bottom: 0;
}
.info-snapshot-btn {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
padding: 3px 7px;
font-size: 11px;
}
.info-snapshot-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.info-row {
display: flex;
align-items: baseline;
@@ -11464,6 +11571,64 @@
max-width: 140px;
}
.info-note-path {
min-width: 0;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-light);
}
.info-note-path-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.info-copy-path-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin: -3px -4px -3px 0;
padding: 3px 5px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--accent);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.info-copy-path-btn:hover {
background: var(--accent-light);
}
.info-copy-path-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.info-copy-path-btn.copied {
color: var(--success);
}
.info-copy-path-btn.error {
color: var(--danger);
}
.info-note-path-value {
display: block;
margin-top: 4px;
overflow: hidden;
color: var(--text-secondary);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 10px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-tags {
display: flex;
flex-wrap: wrap;
@@ -11479,51 +11644,6 @@
padding: 1px 5px;
}
.info-empty {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
padding: 4px 0;
}
.info-versions-list {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 4px;
}
.info-version-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 5px;
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
}
.info-version-item:hover {
background: var(--bg-hover);
}
.info-version-item.active {
background: var(--bg-active);
}
.info-version-date {
font-size: 12px;
color: var(--text-primary);
}
.info-version-size {
font-size: 11px;
color: var(--text-tertiary);
}
.editor-container.mobile .editor-body-row:has(.info-panel) > .editor-body {
display: none;
}
+3 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { showInfo } from '$lib/stores/app';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api';
@@ -144,7 +144,7 @@
<div class="info-panel" onclick={(e) => e.stopPropagation()}>
<div class="info-header">
<h2>Info</h2>
<button class="close-btn" onclick={close}>
<button class="close-btn" onclick={close} aria-label="Close info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@@ -273,6 +273,7 @@
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Collapsible section</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>.</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
+18 -5
View File
@@ -40,6 +40,7 @@
} from '$lib/api';
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { encodeNoteDragPaths } from '$lib/utils/note-drag';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
@@ -265,6 +266,19 @@
if (listContainer) listContainer.scrollTop = 0;
});
// Notes can also be removed from this list by a drop handled in the sidebar.
$effect(() => {
if (selectedPaths.size === 0) return;
const availablePaths = new Set($notes.map((note) => note.path));
const remaining = new Set([...selectedPaths].filter((path) => availablePaths.has(path)));
if (remaining.size === selectedPaths.size) return;
if (remaining.size === 0) {
clearSelection();
} else {
selectedPaths = remaining;
}
});
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
@@ -1259,11 +1273,10 @@
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else {
e.dataTransfer!.setData('text/plain', note.path);
}
const dragPaths = selectedPaths.size > 1 && selectedPaths.has(note.path)
? selectedPaths
: [note.path];
e.dataTransfer!.setData('text/plain', encodeNoteDragPaths(dragPaths));
e.dataTransfer!.effectAllowed = 'move';
}}
ondragover={(e) => {
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import type { NotebookIconId } from '$lib/utils/notebook-icons';
let { icon, size = 18 }: { icon: NotebookIconId; size?: number } = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#if icon === 'book'}
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" />
{:else if icon === 'folder'}
<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2.5h7.5A2.5 2.5 0 0 1 21 9v8.5a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z" />
{:else if icon === 'briefcase'}
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18M10 12v2h4v-2" />
{:else if icon === 'home'}
<path d="m3 11 9-8 9 8" />
<path d="M5 10v11h14V10M9 21v-7h6v7" />
{:else if icon === 'star'}
<path d="m12 2 3.1 6.3 6.9 1-5 4.8 1.2 6.9-6.2-3.3L5.8 21 7 14.1l-5-4.8 6.9-1Z" />
{:else if icon === 'heart'}
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />
{:else if icon === 'lightbulb'}
<path d="M9 18h6M10 22h4" />
<path d="M8.2 15.5A7 7 0 1 1 15.8 15.5c-.7.5-.8 1.1-.8 2.5H9c0-1.4-.1-2-.8-2.5Z" />
{:else if icon === 'code'}
<path d="m8 9-4 3 4 3M16 9l4 3-4 3M14 5l-4 14" />
{:else if icon === 'flask'}
<path d="M9 3h6M10 3v6l-5.5 9.5A1.7 1.7 0 0 0 6 21h12a1.7 1.7 0 0 0 1.5-2.5L14 9V3" />
<path d="M7 16h10" />
{:else if icon === 'graduation'}
<path d="m2 9 10-5 10 5-10 5Z" />
<path d="M6 11.5V16c3.5 2.7 8.5 2.7 12 0v-4.5M22 9v6" />
{:else if icon === 'calendar'}
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01" />
{:else if icon === 'tasks'}
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="m7 8 1.5 1.5L11 7M13 9h4M7 14l1.5 1.5L11 13M13 15h4" />
{:else if icon === 'archive'}
<path d="M4 8v12h16V8M3 4h18v4H3ZM9 12h6" />
{:else if icon === 'globe'}
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
{:else if icon === 'plane'}
<path d="M22 2 9.5 14.5M22 2l-7 20-4-8-8-4Z" />
{:else}
<path d="M12 3a9 9 0 1 0 0 18h1.5a1.5 1.5 0 0 0 0-3H12a1.5 1.5 0 0 1 0-3h3a6 6 0 0 0 0-12Z" />
<path d="M7.5 10h.01M9.5 6.5h.01M14.5 6.5h.01M17 10h.01" />
{/if}
</svg>
+29 -11
View File
@@ -7,7 +7,8 @@
import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors, StartupView } from '$lib/types';
import { normalizeStartupView } from '$lib/utils/startup-view';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -700,6 +701,7 @@
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
@@ -773,6 +775,7 @@
if ($appConfig) {
$appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.startup_view = startupView;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
@@ -795,7 +798,7 @@
$appConfig.show_daily_notes = showDailyNotes;
$appConfig.show_trash = showTrash;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, 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)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -975,6 +978,7 @@
if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
startupView = normalizeStartupView($appConfig.startup_view);
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
@@ -1151,6 +1155,29 @@
</div>
{/if}
<div class="settings-section">
<h3>Startup</h3>
<div class="setting-label">
<span class="setting-name">Default view</span>
<span class="setting-desc">Used when session restore is off or the previous view is unavailable.</span>
</div>
<div class="setting-options" style="margin-top: 8px;">
<button class="option-btn" class:active={startupView === 'all'} onclick={() => { startupView = 'all'; saveGeneralSettings(); }}>All Notes</button>
<button class="option-btn" class:active={startupView === 'quickaccess'} onclick={() => { startupView = 'quickaccess'; saveGeneralSettings(); }}>Quick Access</button>
<button class="option-btn" class:active={startupView === 'tasks'} onclick={() => { startupView = 'tasks'; saveGeneralSettings(); }}>Tasks</button>
<button class="option-btn" class:active={startupView === 'daily'} onclick={() => { startupView = 'daily'; saveGeneralSettings(); }}>Daily Notes</button>
</div>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the last view and note when possible. This overrides the default view.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
<h3>Time Format</h3>
<div class="setting-options">
@@ -1282,15 +1309,6 @@
<span class="toggle-knob"></span>
</button>
</label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div>
<div class="settings-section">
+316 -32
View File
@@ -27,6 +27,14 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import type { NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
import { decodeNoteDragPaths } from '$lib/utils/note-drag';
import NotebookGlyph from './NotebookGlyph.svelte';
import {
NOTEBOOK_ICON_OPTIONS,
decodeBuiltinNotebookIcon,
encodeBuiltinNotebookIcon,
type NotebookIconId
} from '$lib/utils/notebook-icons';
let { onViewChanged = () => {} }: {
onViewChanged?: () => void;
@@ -78,6 +86,8 @@
}
let sortedNotebooks = $derived(sortNotebooksTree($notebooks));
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let iconPickerNotebook = $state<NotebookEntry | null>(null);
let iconPickerElement = $state<HTMLDivElement | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
@@ -340,22 +350,32 @@
async function handleNoteDrop(e: DragEvent, nb: NotebookEntry) {
e.preventDefault();
dropTargetPath = null;
const notePath = e.dataTransfer?.getData('text/plain');
if (!notePath) return;
// Don't move if already in this notebook
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
if (noteDir === nb.path) return;
try {
const newPath = await moveNote(notePath, nb.path);
$notes = $notes.filter(n => n.path !== notePath);
if ($activeNotePath === notePath) {
$activeNotePath = newPath;
$activeNote = await readNote(newPath);
const payload = e.dataTransfer?.getData('text/plain') ?? '';
const notePaths = [...new Set(decodeNoteDragPaths(payload))]
.filter((path) => norm(parentOf(path)) !== norm(nb.path));
if (notePaths.length === 0) return;
const movedPaths = new Map<string, string>();
for (const notePath of notePaths) {
try {
movedPaths.set(notePath, await moveNote(notePath, nb.path));
} catch (e) {
console.error('Failed to move note:', notePath, e);
}
await refresh();
} catch (e) {
console.error('Failed to move note:', e);
}
if (movedPaths.size === 0) return;
$notes = $notes.filter((note) => !movedPaths.has(note.path));
const activeNewPath = $activeNotePath ? movedPaths.get($activeNotePath) : undefined;
if (activeNewPath) {
$activeNotePath = activeNewPath;
try {
$activeNote = await readNote(activeNewPath);
} catch (e) {
console.error('Failed to reload moved note:', e);
}
}
await refresh();
}
async function handleNotebookDrop(e: DragEvent, destPath: string) {
@@ -558,21 +578,38 @@
renameInput?.select();
}
async function handleSetIcon(nb: NotebookEntry) {
async function openIconPicker(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = nb;
await tick();
iconPickerElement?.focus();
}
async function handleBuiltinIcon(nb: NotebookEntry, icon: NotebookIconId) {
try {
const value = encodeBuiltinNotebookIcon(icon);
await setNotebookIcon(nb.relative_path, value);
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: value };
iconPickerNotebook = null;
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
}
async function handleCustomIcon(nb: NotebookEntry) {
iconPickerNotebook = null;
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'] }]
});
if (!selected) return;
const filePath = typeof selected === 'string' ? selected : selected;
// Read the file and save as attachment
const data = await readFile(filePath as string);
const fileName = (filePath as string).split('/').pop() || 'icon.png';
const filePath = selected as string;
const data = await readFile(filePath);
const fileName = baseOf(filePath) || 'icon.png';
const iconRelative = await saveAttachment(`notebook-icon-${fileName}`, Array.from(data));
await setNotebookIcon(nb.relative_path, iconRelative);
$notebookIcons = await getNotebookIcons();
$notebookIcons = { ...$notebookIcons, [nb.relative_path]: iconRelative };
} catch (e) {
console.error('Failed to set notebook icon:', e);
}
@@ -580,9 +617,12 @@
async function handleRemoveIcon(nb: NotebookEntry) {
contextMenu = null;
iconPickerNotebook = null;
try {
await setNotebookIcon(nb.relative_path, null);
$notebookIcons = await getNotebookIcons();
const icons = { ...$notebookIcons };
delete icons[nb.relative_path];
$notebookIcons = icons;
} catch (e) {
console.error('Failed to remove notebook icon:', e);
}
@@ -590,7 +630,7 @@
function getNotebookIconSrc(nb: NotebookEntry): string | null {
const iconPath = $notebookIcons[nb.relative_path];
if (!iconPath) return null;
if (!iconPath || iconPath.startsWith('builtin:')) return null;
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return null;
return convertFileSrc(`${vaultRoot}/${iconPath}`);
@@ -650,7 +690,7 @@
}
</script>
<svelte:window onclick={handleWindowClick} />
<svelte:window onclick={handleWindowClick} onkeydown={(e) => { if (e.key === 'Escape') iconPickerNotebook = null; }} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile}
@@ -913,16 +953,10 @@
<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="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
Rename
</button>
<button onclick={() => handleSetIcon(contextMenu!.notebook)}>
<button onclick={() => openIconPicker(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><line x1="9" y1="9" x2="9.01" y2="9" /><line x1="15" y1="9" x2="15.01" y2="9" /></svg>
Set Icon
{$notebookIcons[contextMenu.notebook.relative_path] ? 'Change Icon...' : 'Set Icon...'}
</button>
{#if $notebookIcons[contextMenu.notebook.relative_path]}
<button onclick={() => handleRemoveIcon(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
Remove Icon
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.notebook)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2" /></svg>
Delete
@@ -930,6 +964,50 @@
</div>
{/if}
{#if iconPickerNotebook}
<div class="icon-picker-overlay" class:mobile={isMobile}>
<button class="icon-picker-backdrop" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}></button>
<div bind:this={iconPickerElement} class="icon-picker" role="dialog" aria-modal="true" aria-label={`Choose an icon for ${iconPickerNotebook.name}`} tabindex="-1">
<header class="icon-picker-header">
<div>
<h3>Notebook icon</h3>
<p>{iconPickerNotebook.name}</p>
</div>
<button class="icon-picker-close" aria-label="Close icon picker" onclick={() => iconPickerNotebook = null}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</header>
<div class="icon-picker-grid">
{#each NOTEBOOK_ICON_OPTIONS as option}
<button
class="icon-picker-option"
class:active={$notebookIcons[iconPickerNotebook.relative_path] === encodeBuiltinNotebookIcon(option.id)}
aria-label={option.label}
title={option.label}
onclick={() => handleBuiltinIcon(iconPickerNotebook!, option.id)}
>
<NotebookGlyph icon={option.id} size={20} />
<span>{option.label}</span>
</button>
{/each}
</div>
<div class="icon-picker-actions">
<button onclick={() => handleCustomIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><path d="m21 15-5-5L5 21" /></svg>
Custom image...
</button>
{#if $notebookIcons[iconPickerNotebook.relative_path]}
<button class="remove" onclick={() => handleRemoveIcon(iconPickerNotebook!)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13" /></svg>
Use default
</button>
{/if}
<p class="icon-picker-recommendation">Recommended size: <strong>1:1 aspect ratio</strong></p>
</div>
</div>
</div>
{/if}
{#if trashContextMenu}
{#if isMobile}
<button type="button" class="context-menu-backdrop" aria-label="Close trash actions" onclick={() => trashContextMenu = null}></button>
@@ -964,6 +1042,7 @@
{@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)}
{@const builtinIcon = decodeBuiltinNotebookIcon($notebookIcons[nb.relative_path])}
{#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input
@@ -1052,7 +1131,9 @@
{:else}
<span class="chevron-spacer"></span>
{/if}
{#if iconSrc}
{#if builtinIcon}
<span class="notebook-builtin-icon"><NotebookGlyph icon={builtinIcon} size={18} /></span>
{:else if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" />
{:else if isCollapsed || nb.children.length === 0}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
@@ -1408,6 +1489,15 @@
flex-shrink: 0;
}
.notebook-builtin-icon {
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: var(--text-secondary);
flex-shrink: 0;
}
.notebook-name {
flex: 1;
text-align: left;
@@ -1569,6 +1659,195 @@
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.icon-picker-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: rgba(8, 10, 18, 0.42);
cursor: default;
}
.icon-picker {
position: relative;
z-index: 1;
width: min(360px, 100%);
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border-light);
}
.icon-picker-header h3,
.icon-picker-header p {
margin: 0;
}
.icon-picker-header h3 {
color: var(--text-primary);
font-size: 15px;
font-weight: 650;
}
.icon-picker-header p {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.icon-picker-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.icon-picker-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
padding: 14px;
}
.icon-picker-option {
min-width: 0;
min-height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 3px 6px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.icon-picker-option span {
max-width: 100%;
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-picker-option:hover {
border-color: var(--border-color);
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-option.active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent);
}
.icon-picker-option.active span {
color: var(--accent);
}
.icon-picker-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--border-light);
}
.icon-picker-recommendation {
flex: 0 0 100%;
margin: 2px 0 0;
color: var(--text-tertiary);
font-size: 11px;
line-height: 1.3;
text-align: center;
}
.icon-picker-actions button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.icon-picker-actions button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-picker-actions button.remove {
background: transparent;
}
.icon-picker-overlay.mobile {
place-items: end center;
padding:
20px
calc(12px + env(safe-area-inset-right, 0px))
calc(12px + env(safe-area-inset-bottom, 0px))
calc(12px + env(safe-area-inset-left, 0px));
}
.icon-picker-overlay.mobile .icon-picker {
width: 100%;
max-height: calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
}
.icon-picker-overlay.mobile .icon-picker-option {
min-height: 64px;
}
.icon-picker-overlay.mobile .icon-picker-actions button {
min-height: 44px;
font-size: 14px;
}
.delete-confirm-overlay {
position: fixed;
inset: 0;
@@ -1707,6 +1986,11 @@
height: 24px;
}
.sidebar.mobile .notebook-builtin-icon {
width: 24px;
height: 24px;
}
.sidebar.mobile .new-notebook-input {
padding: 4px 12px;
}
+27
View File
@@ -0,0 +1,27 @@
import type { Node as ProseMirrorNode, Schema } from '@tiptap/pm/model';
export type MixedListName = 'bulletList' | 'taskList';
export function convertListNode(
schema: Schema,
listNode: ProseMirrorNode,
targetListName: MixedListName,
): ProseMirrorNode | null {
if (
(listNode.type.name !== 'bulletList' && listNode.type.name !== 'taskList') ||
listNode.type.name === targetListName
) {
return null;
}
const targetListType = schema.nodes[targetListName];
const targetItemType = schema.nodes[targetListName === 'taskList' ? 'taskItem' : 'listItem'];
if (!targetListType || !targetItemType) return null;
const itemAttrs = targetListName === 'taskList' ? { checked: false } : null;
const convertedItems = listNode.content.content.map((item) =>
targetItemType.create(itemAttrs, item.content, item.marks)
);
return targetListType.create(null, convertedItems, listNode.marks);
}
+3
View File
@@ -81,6 +81,8 @@ export interface CustomTheme {
colors: CustomThemeColors;
}
export type StartupView = "all" | "quickaccess" | "tasks" | "daily";
export interface AppConfig {
vaults: VaultConfig[];
active_vault: string | null;
@@ -130,6 +132,7 @@ export interface AppConfig {
show_tray_icon: boolean;
close_to_tray: boolean;
enable_wiki_links: boolean;
startup_view: StartupView;
restore_last_session: boolean;
sync_provider: string | null;
webdav_url: string | null;
+7
View File
@@ -0,0 +1,7 @@
export function encodeNoteDragPaths(paths: Iterable<string>): string {
return [...paths].join("\n");
}
export function decodeNoteDragPaths(payload: string): string[] {
return payload.split(/\r?\n/).filter((path) => path.length > 0);
}
+50
View File
@@ -0,0 +1,50 @@
export const NOTEBOOK_ICON_OPTIONS = [
{ id: 'book', label: 'Book' },
{ id: 'folder', label: 'Folder' },
{ id: 'briefcase', label: 'Work' },
{ id: 'home', label: 'Home' },
{ id: 'star', label: 'Star' },
{ id: 'heart', label: 'Heart' },
{ id: 'lightbulb', label: 'Ideas' },
{ id: 'code', label: 'Code' },
{ id: 'flask', label: 'Research' },
{ id: 'graduation', label: 'Study' },
{ id: 'calendar', label: 'Calendar' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'archive', label: 'Archive' },
{ id: 'globe', label: 'Globe' },
{ id: 'plane', label: 'Travel' },
{ id: 'palette', label: 'Creative' }
] as const;
export type NotebookIconId = (typeof NOTEBOOK_ICON_OPTIONS)[number]['id'];
const BUILTIN_PREFIX = 'builtin:';
const BUILTIN_IDS: Record<NotebookIconId, true> = {
book: true,
folder: true,
briefcase: true,
home: true,
star: true,
heart: true,
lightbulb: true,
code: true,
flask: true,
graduation: true,
calendar: true,
tasks: true,
archive: true,
globe: true,
plane: true,
palette: true
};
export function encodeBuiltinNotebookIcon(icon: NotebookIconId): string {
return `${BUILTIN_PREFIX}${icon}`;
}
export function decodeBuiltinNotebookIcon(value: string | null | undefined): NotebookIconId | null {
if (!value?.startsWith(BUILTIN_PREFIX)) return null;
const icon = value.slice(BUILTIN_PREFIX.length);
return Object.hasOwn(BUILTIN_IDS, icon) ? icon as NotebookIconId : null;
}
+57
View File
@@ -0,0 +1,57 @@
import type { StartupView } from "../types";
type RestorableListView = StartupView | "trash";
export type StartupTarget =
| { mode: RestorableListView }
| { mode: "notebook"; notebookPath: string }
| { mode: "tag"; tag: string };
export interface StartupState {
startupView: unknown;
restoreLastSession: boolean;
lastViewMode: unknown;
lastNotebook: string | null;
lastTag: string | null;
}
export function normalizeStartupView(value: unknown): StartupView {
switch (value) {
case "daily":
case "quickaccess":
case "tasks":
return value;
default:
return "all";
}
}
function restorableListView(value: unknown): RestorableListView | null {
switch (value) {
case "all":
case "daily":
case "quickaccess":
case "tasks":
case "trash":
return value;
default:
return null;
}
}
export function resolveStartupTarget(state: StartupState): StartupTarget {
const fallback: StartupTarget = {
mode: normalizeStartupView(state.startupView),
};
if (!state.restoreLastSession) return fallback;
if (state.lastViewMode === "notebook" && typeof state.lastNotebook === "string") {
return { mode: "notebook", notebookPath: state.lastNotebook };
}
if (state.lastViewMode === "tag" && state.lastTag) {
return { mode: "tag", tag: state.lastTag };
}
const listView = restorableListView(state.lastViewMode);
return listView ? { mode: listView } : fallback;
}
-31
View File
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const sidebar = await readFile(
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
'utf8'
);
test('mobile notebook rows expose an accessible actions button', () => {
assert.match(sidebar, /\{#if isMobile\}[\s\S]{0,500}class="notebook-actions-btn"/);
assert.match(sidebar, /aria-label=\{`Actions for \$\{nb\.name\}`\}/);
assert.match(sidebar, /onclick=\{\(e\) => openNotebookMenu\(e, nb\)\}/);
});
test('the notebook context menu receives its mobile styling outside the sidebar', () => {
assert.match(sidebar, /class="context-menu" class:mobile=\{isMobile\}/);
assert.match(sidebar, /\.context-menu\.mobile\s*\{/);
});
test('mobile action sheets stay inside every safe area and prevent tap-through', () => {
assert.match(sidebar, /class="context-menu-backdrop"/);
assert.match(sidebar, /safe-area-inset-left/);
assert.match(sidebar, /safe-area-inset-right/);
assert.match(sidebar, /safe-area-inset-top/);
assert.match(sidebar, /safe-area-inset-bottom/);
});
test('the actions button remains inside the notebook manual-sort drop target', () => {
assert.match(sidebar, /<div class="notebook-row" data-nb-path=\{nb\.path\}>/);
});