mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
Add opt-in title bar note switcher
This commit is contained in:
@@ -1459,6 +1459,7 @@ pub fn set_general_settings(
|
|||||||
close_to_tray: bool,
|
close_to_tray: bool,
|
||||||
enable_wiki_links: bool,
|
enable_wiki_links: bool,
|
||||||
show_note_dates: bool,
|
show_note_dates: bool,
|
||||||
|
show_note_switcher: bool,
|
||||||
startup_view: StartupView,
|
startup_view: StartupView,
|
||||||
restore_last_session: bool,
|
restore_last_session: bool,
|
||||||
show_all_notes: bool,
|
show_all_notes: bool,
|
||||||
@@ -1470,6 +1471,7 @@ pub fn set_general_settings(
|
|||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
config.compact_notes = compact_notes;
|
config.compact_notes = compact_notes;
|
||||||
config.show_note_dates = show_note_dates;
|
config.show_note_dates = show_note_dates;
|
||||||
|
config.show_note_switcher = show_note_switcher;
|
||||||
config.startup_view = startup_view;
|
config.startup_view = startup_view;
|
||||||
config.restore_last_session = restore_last_session;
|
config.restore_last_session = restore_last_session;
|
||||||
config.show_all_notes = show_all_notes;
|
config.show_all_notes = show_all_notes;
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ pub struct AppConfig {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_note_dates: bool,
|
pub show_note_dates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub show_note_switcher: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub time_format: String,
|
pub time_format: String,
|
||||||
#[serde(default = "default_week_start")]
|
#[serde(default = "default_week_start")]
|
||||||
pub week_start: String,
|
pub week_start: String,
|
||||||
@@ -300,6 +302,7 @@ impl Default for AppConfig {
|
|||||||
content_width: None,
|
content_width: None,
|
||||||
compact_notes: false,
|
compact_notes: false,
|
||||||
show_note_dates: true,
|
show_note_dates: true,
|
||||||
|
show_note_switcher: false,
|
||||||
time_format: "relative".to_string(),
|
time_format: "relative".to_string(),
|
||||||
week_start: "monday".to_string(),
|
week_start: "monday".to_string(),
|
||||||
daily_title_format: "localized".to_string(),
|
daily_title_format: "localized".to_string(),
|
||||||
@@ -539,4 +542,19 @@ mod startup_view_tests {
|
|||||||
assert_eq!(config.system_light_theme, "light");
|
assert_eq!(config.system_light_theme, "light");
|
||||||
assert_eq!(config.system_dark_theme, "dark");
|
assert_eq!(config.system_dark_theme, "dark");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn note_switcher_is_opt_in_for_new_and_existing_configs() {
|
||||||
|
let config = AppConfig::default();
|
||||||
|
assert!(!config.show_note_switcher);
|
||||||
|
|
||||||
|
let mut value = serde_json::to_value(config).unwrap();
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("show_note_switcher");
|
||||||
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
|
assert!(!config.show_note_switcher);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export async function setGeneralSettings(
|
|||||||
closeToTray: boolean,
|
closeToTray: boolean,
|
||||||
enableWikiLinks: boolean,
|
enableWikiLinks: boolean,
|
||||||
showNoteDates: boolean,
|
showNoteDates: boolean,
|
||||||
|
showNoteSwitcher: boolean,
|
||||||
startupView: StartupView,
|
startupView: StartupView,
|
||||||
restoreLastSession: boolean,
|
restoreLastSession: boolean,
|
||||||
showAllNotes: boolean,
|
showAllNotes: boolean,
|
||||||
@@ -305,6 +306,7 @@ export async function setGeneralSettings(
|
|||||||
closeToTray,
|
closeToTray,
|
||||||
enableWikiLinks,
|
enableWikiLinks,
|
||||||
showNoteDates,
|
showNoteDates,
|
||||||
|
showNoteSwitcher,
|
||||||
startupView,
|
startupView,
|
||||||
restoreLastSession,
|
restoreLastSession,
|
||||||
showAllNotes,
|
showAllNotes,
|
||||||
|
|||||||
@@ -296,6 +296,24 @@
|
|||||||
if (isMobile) $mobileView = 'editor';
|
if (isMobile) $mobileView = 'editor';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectNoteFromSwitcher(path: string): Promise<boolean> {
|
||||||
|
const currentPath = $activeNotePath;
|
||||||
|
if (currentPath && currentPath.replace(/\\/g, '/') === path.replace(/\\/g, '/')) return true;
|
||||||
|
editor?.flushSave();
|
||||||
|
try {
|
||||||
|
const content = await readNote(path);
|
||||||
|
$viewerNote = null;
|
||||||
|
$activeNote = content;
|
||||||
|
$activeNotePath = path;
|
||||||
|
$editorDirty = false;
|
||||||
|
handleNoteSelected(path, content.content);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to switch note:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleViewChanged() {
|
function handleViewChanged() {
|
||||||
taskNoteOpened = false;
|
taskNoteOpened = false;
|
||||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||||
@@ -997,7 +1015,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
|
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} onSelectNote={selectNoteFromSwitcher} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
{#if !$focusMode}
|
{#if !$focusMode}
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { activeNote, activeNotePath, appConfig, navHistory } from '$lib/stores/app';
|
||||||
|
import { getAllNoteTitles, getQuickAccess } from '$lib/api';
|
||||||
|
import { openNoteWindow } from '$lib/utils/window';
|
||||||
|
import {
|
||||||
|
buildNoteSwitcherSections,
|
||||||
|
type NoteSwitcherNote,
|
||||||
|
type NoteSwitcherRow,
|
||||||
|
type NoteSwitcherSections
|
||||||
|
} from '$lib/utils/note-switcher';
|
||||||
|
import type { NoteEntry, NoteTitleEntry } from '$lib/types';
|
||||||
|
|
||||||
|
let { onSelectNote = async () => false }: {
|
||||||
|
onSelectNote?: (path: string) => Promise<boolean>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let wrapper: HTMLDivElement;
|
||||||
|
let trigger: HTMLButtonElement;
|
||||||
|
let popover = $state<HTMLDivElement | null>(null);
|
||||||
|
let open = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let selectingPath = $state<string | null>(null);
|
||||||
|
let sections = $state<NoteSwitcherSections>({ recent: [], quickAccess: [] });
|
||||||
|
let loadGeneration = 0;
|
||||||
|
|
||||||
|
function normalizedPath(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideVault(path: string, vaultPath: string): boolean {
|
||||||
|
return normalizedPath(path).startsWith(`${normalizedPath(vaultPath)}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultRelativePath(path: string, vaultPath: string): string {
|
||||||
|
return normalizedPath(path).slice(normalizedPath(vaultPath).length + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinVaultPath(vaultPath: string, relativePath: string): string {
|
||||||
|
const separator = vaultPath.includes('\\') && !vaultPath.includes('/') ? '\\' : '/';
|
||||||
|
const root = vaultPath.replace(/[\\/]+$/, '');
|
||||||
|
return `${root}${separator}${relativePath.replace(/[\\/]/g, separator)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleEntryToNote(entry: NoteTitleEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||||
|
const relativePath = entry.path.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (!relativePath || relativePath.split('/').includes('..')) return null;
|
||||||
|
const path = joinVaultPath(vaultPath, relativePath);
|
||||||
|
if (!isInsideVault(path, vaultPath)) return null;
|
||||||
|
return { path, title: entry.title, relativePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickAccessEntryToNote(entry: NoteEntry, vaultPath: string): NoteSwitcherNote | null {
|
||||||
|
if (!isInsideVault(entry.path, vaultPath)) return null;
|
||||||
|
return {
|
||||||
|
path: entry.path,
|
||||||
|
title: entry.meta.title,
|
||||||
|
relativePath: entry.relative_path.replace(/\\/g, '/')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusInitialRow() {
|
||||||
|
await tick();
|
||||||
|
if (!open) return;
|
||||||
|
const current = wrapper.querySelector<HTMLButtonElement>('.note-primary[aria-current="true"]');
|
||||||
|
const first = wrapper.querySelector<HTMLButtonElement>('.note-primary');
|
||||||
|
(current ?? first ?? popover)?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSections(generation: number) {
|
||||||
|
const vaultPath = $appConfig?.active_vault;
|
||||||
|
if (!vaultPath) {
|
||||||
|
sections = { recent: [], quickAccess: [] };
|
||||||
|
loading = false;
|
||||||
|
await focusInitialRow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [titlesResult, quickAccessResult] = await Promise.allSettled([
|
||||||
|
getAllNoteTitles(),
|
||||||
|
getQuickAccess()
|
||||||
|
]);
|
||||||
|
if (!open || generation !== loadGeneration) return;
|
||||||
|
|
||||||
|
if (titlesResult.status === 'rejected') {
|
||||||
|
console.error('Failed to load notes for note switcher:', titlesResult.reason);
|
||||||
|
}
|
||||||
|
if (quickAccessResult.status === 'rejected') {
|
||||||
|
console.error('Failed to load Quick Access for note switcher:', quickAccessResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownNotes = titlesResult.status === 'fulfilled'
|
||||||
|
? titlesResult.value
|
||||||
|
.map((entry) => titleEntryToNote(entry, vaultPath))
|
||||||
|
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||||
|
: [];
|
||||||
|
const currentPath = $activeNotePath;
|
||||||
|
if (currentPath && isInsideVault(currentPath, vaultPath)) {
|
||||||
|
const currentKey = normalizedPath(currentPath);
|
||||||
|
if (!knownNotes.some((note) => normalizedPath(note.path) === currentKey)) {
|
||||||
|
knownNotes.push({
|
||||||
|
path: currentPath,
|
||||||
|
title: $activeNote?.meta.title || currentPath.split(/[\\/]/).pop()?.replace(/\.md$/i, '') || 'Untitled',
|
||||||
|
relativePath: vaultRelativePath(currentPath, vaultPath)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickAccessNotes = quickAccessResult.status === 'fulfilled'
|
||||||
|
? quickAccessResult.value
|
||||||
|
.map((entry) => quickAccessEntryToNote(entry, vaultPath))
|
||||||
|
.filter((entry): entry is NoteSwitcherNote => entry !== null)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
sections = buildNoteSwitcherSections({
|
||||||
|
currentPath,
|
||||||
|
historyPaths: $navHistory.stack,
|
||||||
|
knownNotes,
|
||||||
|
quickAccessNotes
|
||||||
|
});
|
||||||
|
loading = false;
|
||||||
|
await focusInitialRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSwitcher() {
|
||||||
|
if (open) {
|
||||||
|
open = false;
|
||||||
|
loadGeneration += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open = true;
|
||||||
|
loading = true;
|
||||||
|
selectingPath = null;
|
||||||
|
sections = { recent: [], quickAccess: [] };
|
||||||
|
loadGeneration += 1;
|
||||||
|
void refreshSections(loadGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSwitcher(restoreTriggerFocus: boolean) {
|
||||||
|
open = false;
|
||||||
|
loadGeneration += 1;
|
||||||
|
if (restoreTriggerFocus) {
|
||||||
|
await tick();
|
||||||
|
trigger?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectNote(row: NoteSwitcherRow) {
|
||||||
|
if (selectingPath !== null) return;
|
||||||
|
selectingPath = row.path;
|
||||||
|
try {
|
||||||
|
if (await onSelectNote(row.path)) await closeSwitcher(false);
|
||||||
|
} finally {
|
||||||
|
selectingPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopoverKeydown(event: KeyboardEvent) {
|
||||||
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||||
|
const buttons = Array.from(wrapper.querySelectorAll<HTMLButtonElement>('.note-primary:not(:disabled)'));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const activeRow = (document.activeElement as HTMLElement | null)?.closest('.note-row');
|
||||||
|
const activeButton = activeRow?.querySelector<HTMLButtonElement>('.note-primary') ?? null;
|
||||||
|
const currentIndex = activeButton ? buttons.indexOf(activeButton) : -1;
|
||||||
|
let nextIndex: number;
|
||||||
|
if (event.key === 'Home') nextIndex = 0;
|
||||||
|
else if (event.key === 'End') nextIndex = buttons.length - 1;
|
||||||
|
else if (event.key === 'ArrowDown') nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
|
||||||
|
else nextIndex = currentIndex < 0 ? buttons.length - 1 : (currentIndex - 1 + buttons.length) % buttons.length;
|
||||||
|
buttons[nextIndex].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleOutsideMouseDown = (event: MouseEvent) => {
|
||||||
|
if (!wrapper.contains(event.target as Node)) void closeSwitcher(false);
|
||||||
|
};
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void closeSwitcher(true);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideMouseDown, true);
|
||||||
|
document.addEventListener('keydown', handleEscape, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideMouseDown, true);
|
||||||
|
document.removeEventListener('keydown', handleEscape, true);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="note-switcher" bind:this={wrapper}>
|
||||||
|
<button
|
||||||
|
bind:this={trigger}
|
||||||
|
class="switcher-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-label="Switch note"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggleSwitcher}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="7" y="4" width="12" height="14" rx="2" />
|
||||||
|
<path d="M5 7H4a2 2 0 00-2 2v9a2 2 0 002 2h9a2 2 0 002-2v-1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={popover}
|
||||||
|
class="switcher-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Note switcher"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={handlePopoverKeydown}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<div class="switcher-status">Loading notes…</div>
|
||||||
|
{:else if sections.recent.length === 0 && sections.quickAccess.length === 0}
|
||||||
|
<div class="switcher-status">No notes available</div>
|
||||||
|
{:else}
|
||||||
|
{#each [
|
||||||
|
{ label: 'Recent', rows: sections.recent },
|
||||||
|
{ label: 'Quick Access', rows: sections.quickAccess }
|
||||||
|
] as section (section.label)}
|
||||||
|
{#if section.rows.length > 0}
|
||||||
|
<section class="switcher-section" aria-labelledby={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>
|
||||||
|
<h2 id={`note-switcher-${section.label.replace(' ', '-').toLowerCase()}`}>{section.label}</h2>
|
||||||
|
{#each section.rows as row (row.path)}
|
||||||
|
<div class="note-row" class:current-row={row.current}>
|
||||||
|
{#if row.current}<span class="current-marker" aria-hidden="true"></span>{/if}
|
||||||
|
<button
|
||||||
|
class="note-primary"
|
||||||
|
type="button"
|
||||||
|
aria-current={row.current ? 'true' : undefined}
|
||||||
|
disabled={selectingPath !== null}
|
||||||
|
onclick={() => selectNote(row)}
|
||||||
|
>
|
||||||
|
<span class="note-title">{row.title}</span>
|
||||||
|
<span class="note-folder">{row.folder}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="open-window"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Open ${row.title} in new window`}
|
||||||
|
title={`Open ${row.title} in new window`}
|
||||||
|
onclick={() => openNoteWindow(row.path, row.title)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4" y="4" width="13" height="13" rx="2" />
|
||||||
|
<path d="M10 14L20 4m-6 0h6v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.note-switcher {
|
||||||
|
position: relative;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger:hover,
|
||||||
|
.switcher-trigger[aria-expanded='true'] {
|
||||||
|
background: var(--bg-tertiary, var(--bg-hover));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-trigger:focus-visible,
|
||||||
|
.note-primary:focus-visible,
|
||||||
|
.open-window:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
width: min(320px, calc(100vw - 24px));
|
||||||
|
max-height: min(420px, calc(100vh - 58px));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-section + .switcher-section {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 5px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 8px 4px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row:hover,
|
||||||
|
.note-row:focus-within {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row.current-row {
|
||||||
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-primary {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 38px 6px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-row .note-primary {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-primary:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title,
|
||||||
|
.note-folder {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-folder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-window {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-row:hover .open-window,
|
||||||
|
.note-row:focus-within .open-window {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-window:hover {
|
||||||
|
background: var(--bg-tertiary, var(--bg-hover));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switcher-status {
|
||||||
|
padding: 22px 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, resolvedTheme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { showSettings, theme, resolvedTheme, appConfig, platformIsMobile, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
@@ -750,6 +750,7 @@
|
|||||||
// General settings
|
// General settings
|
||||||
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
let compactNotes = $state($appConfig?.compact_notes ?? false);
|
||||||
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
|
||||||
|
let showNoteSwitcher = $state($appConfig?.show_note_switcher ?? false);
|
||||||
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
let startupView = $state<StartupView>(normalizeStartupView($appConfig?.startup_view));
|
||||||
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
|
||||||
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
let timeFormat = $state($appConfig?.time_format ?? 'relative');
|
||||||
@@ -825,6 +826,7 @@
|
|||||||
if ($appConfig) {
|
if ($appConfig) {
|
||||||
$appConfig.compact_notes = compactNotes;
|
$appConfig.compact_notes = compactNotes;
|
||||||
$appConfig.show_note_dates = showNoteDates;
|
$appConfig.show_note_dates = showNoteDates;
|
||||||
|
$appConfig.show_note_switcher = showNoteSwitcher;
|
||||||
$appConfig.startup_view = startupView;
|
$appConfig.startup_view = startupView;
|
||||||
$appConfig.restore_last_session = restoreLastSession;
|
$appConfig.restore_last_session = restoreLastSession;
|
||||||
$appConfig.time_format = timeFormat;
|
$appConfig.time_format = timeFormat;
|
||||||
@@ -849,7 +851,7 @@
|
|||||||
$appConfig.show_daily_notes = showDailyNotes;
|
$appConfig.show_daily_notes = showDailyNotes;
|
||||||
$appConfig.show_trash = showTrash;
|
$appConfig.show_trash = showTrash;
|
||||||
}
|
}
|
||||||
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, showLinkArrows, defaultViewMode, newNotesInSourceMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, showNoteSwitcher, startupView, restoreLastSession, showAllNotes, showQuickAccess, showTasks, showDailyNotes, showTrash)
|
||||||
.catch((e) => console.error('Failed to save general settings:', e));
|
.catch((e) => console.error('Failed to save general settings:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1033,7 @@
|
|||||||
if ($appConfig) {
|
if ($appConfig) {
|
||||||
compactNotes = $appConfig.compact_notes ?? false;
|
compactNotes = $appConfig.compact_notes ?? false;
|
||||||
showNoteDates = $appConfig.show_note_dates ?? true;
|
showNoteDates = $appConfig.show_note_dates ?? true;
|
||||||
|
showNoteSwitcher = $appConfig.show_note_switcher ?? false;
|
||||||
startupView = normalizeStartupView($appConfig.startup_view);
|
startupView = normalizeStartupView($appConfig.startup_view);
|
||||||
restoreLastSession = $appConfig.restore_last_session ?? false;
|
restoreLastSession = $appConfig.restore_last_session ?? false;
|
||||||
timeFormat = $appConfig.time_format ?? 'relative';
|
timeFormat = $appConfig.time_format ?? 'relative';
|
||||||
@@ -1209,6 +1212,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if !$platformIsMobile}
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Interface</h3>
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<span class="setting-label">
|
||||||
|
<span class="setting-name">Show note switcher in title bar</span>
|
||||||
|
<span class="setting-desc">Show recent and Quick Access notes in the title bar.</span>
|
||||||
|
</span>
|
||||||
|
<button class="toggle-switch" class:on={showNoteSwitcher} onclick={() => { showNoteSwitcher = !showNoteSwitcher; saveGeneralSettings(); }}>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Startup</h3>
|
<h3>Startup</h3>
|
||||||
<div class="setting-label">
|
<div class="setting-label">
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||||
import { syncNow } from '$lib/api';
|
import { syncNow } from '$lib/api';
|
||||||
|
import NoteSwitcher from './NoteSwitcher.svelte';
|
||||||
|
|
||||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
let {
|
||||||
|
onNewNote = () => {},
|
||||||
|
onDailyNote = () => {},
|
||||||
|
onSelectNote
|
||||||
|
}: {
|
||||||
onNewNote?: () => void;
|
onNewNote?: () => void;
|
||||||
onDailyNote?: () => void;
|
onDailyNote?: () => void;
|
||||||
|
onSelectNote?: (path: string) => Promise<boolean>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
@@ -30,7 +36,7 @@
|
|||||||
function handleMouseDown(e: MouseEvent) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
|
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions') || target.closest('.titlebar-note-switcher')) return;
|
||||||
|
|
||||||
// Don't start dragging near window edges - let Tauri handle resize
|
// Don't start dragging near window edges - let Tauri handle resize
|
||||||
if (!maximized) {
|
if (!maximized) {
|
||||||
@@ -81,6 +87,11 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if $appConfig?.show_note_switcher}
|
||||||
|
<div class="titlebar-note-switcher">
|
||||||
|
<NoteSwitcher {onSelectNote} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="titlebar-actions">
|
<div class="titlebar-actions">
|
||||||
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
|
<button class="switch-vault-btn" onclick={() => ($vaultReady = false)} title="Switch Vault">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -173,7 +184,13 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-left: 14px;
|
padding-left: 14px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
flex: 1;
|
}
|
||||||
|
|
||||||
|
.titlebar-note-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 16px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titlebar-title {
|
.titlebar-title {
|
||||||
@@ -205,6 +222,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export interface AppConfig {
|
|||||||
content_width: number | null;
|
content_width: number | null;
|
||||||
compact_notes: boolean;
|
compact_notes: boolean;
|
||||||
show_note_dates: boolean;
|
show_note_dates: boolean;
|
||||||
|
show_note_switcher: boolean;
|
||||||
time_format: string;
|
time_format: string;
|
||||||
week_start: string;
|
week_start: string;
|
||||||
daily_title_format: string;
|
daily_title_format: string;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export interface NoteSwitcherNote {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteSwitcherRow {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
folder: string;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteSwitcherSections {
|
||||||
|
recent: NoteSwitcherRow[];
|
||||||
|
quickAccess: NoteSwitcherRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildNoteSwitcherSectionsOptions {
|
||||||
|
currentPath: string | null;
|
||||||
|
historyPaths: readonly string[];
|
||||||
|
knownNotes: readonly NoteSwitcherNote[];
|
||||||
|
quickAccessNotes: readonly NoteSwitcherNote[];
|
||||||
|
recentLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathKey(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderLabel(relativePath: string): string {
|
||||||
|
const parts = relativePath.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join('/') || 'Unfiled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(note: NoteSwitcherNote, currentPathKey: string | null): NoteSwitcherRow {
|
||||||
|
return {
|
||||||
|
path: note.path,
|
||||||
|
title: note.title,
|
||||||
|
folder: folderLabel(note.relativePath),
|
||||||
|
current: currentPathKey !== null && pathKey(note.path) === currentPathKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNoteSwitcherSections({
|
||||||
|
currentPath,
|
||||||
|
historyPaths,
|
||||||
|
knownNotes,
|
||||||
|
quickAccessNotes,
|
||||||
|
recentLimit = 6
|
||||||
|
}: BuildNoteSwitcherSectionsOptions): NoteSwitcherSections {
|
||||||
|
const knownByPath = new Map(knownNotes.map((note) => [pathKey(note.path), note]));
|
||||||
|
const currentPathKey = currentPath ? pathKey(currentPath) : null;
|
||||||
|
const recent: NoteSwitcherRow[] = [];
|
||||||
|
const shownPaths = new Set<string>();
|
||||||
|
const limit = Math.max(0, Math.floor(recentLimit));
|
||||||
|
|
||||||
|
const addRecent = (path: string | null) => {
|
||||||
|
if (!path || recent.length >= limit) return;
|
||||||
|
const key = pathKey(path);
|
||||||
|
if (shownPaths.has(key)) return;
|
||||||
|
const note = knownByPath.get(key);
|
||||||
|
if (!note) return;
|
||||||
|
shownPaths.add(key);
|
||||||
|
recent.push(toRow(note, currentPathKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
addRecent(currentPath);
|
||||||
|
for (let index = historyPaths.length - 1; index >= 0 && recent.length < limit; index -= 1) {
|
||||||
|
addRecent(historyPaths[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickAccess: NoteSwitcherRow[] = [];
|
||||||
|
for (const note of quickAccessNotes) {
|
||||||
|
const key = pathKey(note.path);
|
||||||
|
if (shownPaths.has(key) || !knownByPath.has(key)) continue;
|
||||||
|
shownPaths.add(key);
|
||||||
|
quickAccess.push(toRow(note, currentPathKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recent, quickAccess };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user