mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-21 10:27:28 +02:00
Compare commits
17
Commits
221625aabd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2983e3db29 | ||
|
|
cc7c9e2b49 | ||
|
|
2240231f61 | ||
|
|
b2d0c7175e | ||
|
|
1bc0026731 | ||
|
|
41c0e4c993 | ||
|
|
34a0f9efa3 | ||
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c | ||
|
|
d7e4be7f3a | ||
|
|
2aa572dcaa | ||
|
|
32f270ce9e | ||
|
|
a15b267614 | ||
|
|
ce48d77cd8 |
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
|
||||
[](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.3.4)
|
||||
## Download (v1.3.5)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -18,7 +18,7 @@ No cloud, no lock-in.
|
||||
|
||||
The AppImage works only on Fedora 43+, Arch Linux, and openSUSE Tumbleweed (x86_64).
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.AppImage)
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.AppImage)
|
||||
|
||||
#### Distro-specific packages
|
||||
|
||||
@@ -108,26 +108,28 @@ sudo eopkg it helixnotes
|
||||
|
||||
#### Manual package downloads
|
||||
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.4/HelixNotes-1.3.4-1.x86_64.rpm)
|
||||
- [.deb](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_amd64.deb) (Ubuntu 22.04+)
|
||||
- [.rpm](https://download.helixnotes.com/releases/v1.3.5/HelixNotes-1.3.5-1.x86_64.rpm)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_aarch64.dmg) (M-series Macs)
|
||||
[Download .dmg (Apple Silicon)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_aarch64.dmg) (M-series Macs)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
> ```bash
|
||||
> xattr -cr /Applications/HelixNotes.app
|
||||
> ```
|
||||
[Download .dmg (Intel)](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_x64.dmg)
|
||||
|
||||
The Apple Silicon build is signed and notarized by Apple. The Intel build may still trigger a Gatekeeper warning. If macOS reports that the Intel build is damaged, run this once after installing or updating it:
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/HelixNotes.app
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.4/HelixNotes_1.3.4_android.apk)
|
||||
[Download APK](https://download.helixnotes.com/releases/v1.3.5/HelixNotes_1.3.5_android.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -11,7 +11,7 @@
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test": "node --test tests/*.test.mjs src/lib/utils/*.test.mjs",
|
||||
"test:rust": "cargo test --manifest-path src-tauri/Cargo.toml",
|
||||
"lint:rust": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings",
|
||||
"verify": "pnpm check && pnpm test && pnpm test:rust && pnpm lint:rust && pnpm build",
|
||||
|
||||
Generated
+1
-1
@@ -1776,7 +1776,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.3.4"
|
||||
version = "1.3.5"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -470,6 +470,22 @@ pub fn set_font_size(app: AppHandle, state: State<'_, AppState>, size: u32) -> R
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_scroll_to_change_font_size(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.scroll_to_change_font_size = enabled;
|
||||
save_app_config(&config)?;
|
||||
drop(config);
|
||||
|
||||
use tauri::Emitter;
|
||||
app.emit("scroll-to-change-font-size-changed", enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -172,6 +172,7 @@ pub fn run() {
|
||||
commands::export_custom_theme,
|
||||
commands::import_custom_themes,
|
||||
commands::set_font_size,
|
||||
commands::set_scroll_to_change_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
|
||||
@@ -124,6 +124,8 @@ pub struct AppConfig {
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub font_size: Option<u32>,
|
||||
#[serde(default = "default_true")]
|
||||
pub scroll_to_change_font_size: bool,
|
||||
#[serde(default)]
|
||||
pub font_family: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -296,6 +298,7 @@ impl Default for AppConfig {
|
||||
system_dark_theme: default_system_dark_theme(),
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
scroll_to_change_font_size: true,
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
@@ -554,4 +557,19 @@ mod startup_view_tests {
|
||||
|
||||
assert!(!config.show_note_switcher);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_font_sizing_stays_enabled_for_new_and_existing_configs() {
|
||||
let config = AppConfig::default();
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
|
||||
let mut value = serde_json::to_value(config).unwrap();
|
||||
value
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("scroll_to_change_font_size");
|
||||
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert!(config.scroll_to_change_font_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HelixNotes",
|
||||
"label": "main",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"visible": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -71,6 +71,10 @@ export async function setFontSize(size: number): Promise<void> {
|
||||
return invoke("set_font_size", { size });
|
||||
}
|
||||
|
||||
export async function setScrollToChangeFontSize(enabled: boolean): Promise<void> {
|
||||
return invoke("set_scroll_to_change_font_size", { enabled });
|
||||
}
|
||||
|
||||
export async function setFontFamily(family: string): Promise<void> {
|
||||
return invoke("set_font_family", { family });
|
||||
}
|
||||
|
||||
@@ -578,6 +578,11 @@
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||
if (!dismissesAppUi) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ($showSettings) $showSettings = false;
|
||||
else if ($showInfo) $showInfo = false;
|
||||
else if ($focusMode) $focusMode = false;
|
||||
|
||||
@@ -164,10 +164,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showCommandPalette = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
@@ -194,7 +199,7 @@
|
||||
|
||||
{#if $showCommandPalette}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydown={handleKeydown}>
|
||||
<div class="palette-overlay" onclick={() => ($showCommandPalette = false)} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="palette-input-wrapper">
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||
import { clearFormatting } from '$lib/editor/clearFormatting';
|
||||
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||
import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
|
||||
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
||||
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
||||
import GraphView from './GraphView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
@@ -116,9 +118,7 @@
|
||||
let hasPendingBlobs = false;
|
||||
let lastSourceMode = $sourceMode;
|
||||
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
||||
let titleWasStripped = false;
|
||||
let strippedTitle = '';
|
||||
let strippedHeadingPrefix = '';
|
||||
let hiddenTitleHeading: HiddenTitleHeading | null = null;
|
||||
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let taskRevealElement: HTMLElement | null = null;
|
||||
let taskRevealRequest = 0;
|
||||
@@ -2547,10 +2547,7 @@
|
||||
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
|
||||
if (!editor || !wikiLinkMenu) return;
|
||||
const { from } = wikiLinkMenu;
|
||||
// Delete the [[ trigger and query text
|
||||
const to = editor.state.selection.from;
|
||||
editor.chain().focus().deleteRange({ from, to }).run();
|
||||
// Insert the wiki-link mark
|
||||
// For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
|
||||
const displayText = entry.title;
|
||||
let titleAttr = originalRef || entry.title;
|
||||
@@ -2562,17 +2559,16 @@
|
||||
titleAttr = relPath + anchor;
|
||||
}
|
||||
}
|
||||
tick().then(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: displayText,
|
||||
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
|
||||
})
|
||||
.run();
|
||||
});
|
||||
const transaction = replaceWithWikiLink(
|
||||
editor.state,
|
||||
from,
|
||||
to,
|
||||
displayText,
|
||||
{ title: titleAttr, path: entry.path || '', aliased: displayText !== titleAttr },
|
||||
);
|
||||
closeWikiLinkMenu();
|
||||
editor.view.dispatch(transaction);
|
||||
editor.view.focus();
|
||||
}
|
||||
|
||||
function executeWikiLinkCommand(index: number) {
|
||||
@@ -2759,21 +2755,13 @@
|
||||
closeWikiLinkMenu();
|
||||
tick().then(() => {
|
||||
if (!editor) return;
|
||||
// Use a single ProseMirror transaction to replace [[query] with the
|
||||
// wiki-link and clear stored marks atomically, preventing the inclusive
|
||||
// mark from bleeding into subsequent text. Do NOT call deleteRange first
|
||||
// — that would shift positions and make menuFrom/curTo invalid here.
|
||||
const { tr, schema } = editor.view.state;
|
||||
const wikiLinkMark = schema.marks.wikiLink.create({
|
||||
title: noteRef,
|
||||
path: '',
|
||||
aliased: display !== noteRef,
|
||||
});
|
||||
const textNode = schema.text(display, [wikiLinkMark]);
|
||||
tr.replaceWith(menuFrom, curTo, textNode);
|
||||
tr.setSelection(TextSelection.create(tr.doc, menuFrom + display.length));
|
||||
tr.setStoredMarks([]);
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.dispatch(replaceWithWikiLink(
|
||||
editor.state,
|
||||
menuFrom,
|
||||
curTo,
|
||||
display,
|
||||
{ title: noteRef, path: '', aliased: display !== noteRef },
|
||||
));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -3414,44 +3402,13 @@
|
||||
}
|
||||
|
||||
function stripTitleH1(md: string): string {
|
||||
const title = $activeNote?.meta.title;
|
||||
if (!$appConfig?.hide_title_in_body || !title) {
|
||||
titleWasStripped = false;
|
||||
strippedTitle = '';
|
||||
strippedHeadingPrefix = '';
|
||||
return md;
|
||||
}
|
||||
// Find the first non-empty line
|
||||
const lines = md.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
// Check if it's a heading (any level) matching the note title
|
||||
// Normalize: lowercase, collapse whitespace, strip common separators (- - _)
|
||||
const normalize = (s: string) => s.trim().toLowerCase().replace(/[\s\-—_]+/g, ' ');
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match && normalize(match[2]) === normalize(title)) {
|
||||
titleWasStripped = true;
|
||||
strippedTitle = title.trim();
|
||||
strippedHeadingPrefix = match[1]; // preserve original heading level (e.g. "##")
|
||||
lines.splice(i, 1);
|
||||
// Also remove a trailing blank line after the heading if present
|
||||
if (i < lines.length && lines[i].trim() === '') {
|
||||
lines.splice(i, 1);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
break; // First non-empty line isn't a matching heading, stop
|
||||
}
|
||||
titleWasStripped = false;
|
||||
strippedTitle = '';
|
||||
strippedHeadingPrefix = '';
|
||||
return md;
|
||||
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
|
||||
hiddenTitleHeading = result.hiddenTitle;
|
||||
return result.markdown;
|
||||
}
|
||||
|
||||
function restoreTitleH1(md: string): string {
|
||||
if (!titleWasStripped || !strippedTitle) return md;
|
||||
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
|
||||
return restoreTitleHeading(md, hiddenTitleHeading);
|
||||
}
|
||||
|
||||
function editorToMarkdown(): string {
|
||||
@@ -5007,13 +4964,25 @@
|
||||
}
|
||||
|
||||
async function ctxPaste() {
|
||||
if (!editor) return;
|
||||
// insertClipboardImage() saves the attachment before inserting it, so an
|
||||
// unguarded paste here would leave an orphan file behind when the editor
|
||||
// cannot accept the insertion.
|
||||
if (!editor || $readOnly || $viewerNote) { closeTextContextMenu(); return; }
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) editor.chain().focus().insertContent(text).run();
|
||||
if (text) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
closeTextContextMenu();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Paste failed:', e);
|
||||
}
|
||||
// readText() only reads text/plain. An image copied from a browser has none,
|
||||
// so pasting an image from this menu used to do nothing at all. Fall back to
|
||||
// the same native clipboard reader the paste handler uses; it is a no-op when
|
||||
// the clipboard holds no image.
|
||||
await insertClipboardImage();
|
||||
closeTextContextMenu();
|
||||
}
|
||||
|
||||
@@ -5640,17 +5609,20 @@
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// WebKitGTK fallback (bug #218519): older WebKitGTK versions return
|
||||
// empty DataTransferItemList for image pastes. Detect this and read
|
||||
// the image directly from the system clipboard via Rust/arboard.
|
||||
if (items.length === 0) {
|
||||
const hasText = event.clipboardData!.getData('text/plain');
|
||||
const hasHtml = event.clipboardData!.getData('text/html');
|
||||
if (!hasText && !hasHtml) {
|
||||
event.preventDefault();
|
||||
insertClipboardImage();
|
||||
return true;
|
||||
}
|
||||
// WebKitGTK fallback (bug #218519): WebKitGTK never exposes a clipboard image
|
||||
// through DataTransfer, so the loop above cannot fire on Linux and this is the
|
||||
// only path that can work there. The guard used to also require an empty
|
||||
// DataTransferItemList and no text/html, which no real image copy satisfies:
|
||||
// Chromium's "Copy image" writes an <img> HTML fragment alongside the bitmap
|
||||
// (but no text/plain), so the image was dropped and its remote URL pasted
|
||||
// instead. Key off text/plain, and accept HTML that is just an <img>.
|
||||
const hasText = event.clipboardData!.getData('text/plain');
|
||||
const html = event.clipboardData!.getData('text/html');
|
||||
const htmlIsBareImage = /^\s*(?:<meta[^>]*>\s*)?<img\b[^>]*>\s*$/i.test(html);
|
||||
if (!hasText && (!html || htmlIsBareImage)) {
|
||||
event.preventDefault();
|
||||
insertClipboardImage();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -5790,7 +5762,7 @@
|
||||
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||
: 0;
|
||||
const docNonWs = docNonWhitespace();
|
||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
|
||||
resetSourceHistory(sourceContent);
|
||||
lastSourceMode = true;
|
||||
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||
@@ -5817,20 +5789,20 @@
|
||||
};
|
||||
if (isMobile) {
|
||||
// Mobile: editor stays in DOM, just update its content
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
const content = srcText;
|
||||
if (editor) {
|
||||
ignoreNextUpdate = true;
|
||||
editor.commands.setContent(markdownToHtml(content));
|
||||
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
|
||||
tick().then(restoreRichCaret);
|
||||
}
|
||||
} else {
|
||||
// Desktop: destroy old editor (its DOM element is gone),
|
||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||
destroyEditor();
|
||||
const content = srcText || ($activeNote?.content ?? '');
|
||||
const content = srcText;
|
||||
tick().then(() => {
|
||||
if (editorElement && !editor) {
|
||||
createEditor(content);
|
||||
createEditor(restoreTitleH1(content));
|
||||
restoreRichCaret();
|
||||
}
|
||||
});
|
||||
@@ -5963,7 +5935,7 @@
|
||||
const oldPath = $activeNotePath;
|
||||
$activeNote.meta.title = newTitle;
|
||||
// Update stripped title so restoreTitleH1 uses the new title
|
||||
if (titleWasStripped) strippedTitle = newTitle;
|
||||
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
|
||||
$editorDirty = true;
|
||||
// Force save current editor content before renaming so disk is up-to-date
|
||||
await forceSave();
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
openUrl(url).catch(console.error);
|
||||
}
|
||||
@@ -104,7 +111,7 @@
|
||||
|
||||
{#if $showInfo}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="info-overlay" onclick={closeFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="info-overlay" onclick={closeFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h2>Info</h2>
|
||||
|
||||
@@ -44,10 +44,15 @@
|
||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
$showSearch = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
$showSearch = false;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
scrollToSelected();
|
||||
@@ -128,7 +133,7 @@
|
||||
|
||||
{#if $showSearch}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-overlay" onclick={close} onkeydown={handleKeydown}>
|
||||
<div class="search-overlay" onclick={close} onkeydowncapture={handleEscape} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="search-input-wrapper">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setScrollToChangeFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes, getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -521,6 +521,17 @@
|
||||
{ id: 'one-dark', label: 'One Dark', bg: '#282c34', sidebar: '#21252b', accent: '#61afef' },
|
||||
];
|
||||
|
||||
// Keep the default themes pinned at the top and sort the rest alphabetically.
|
||||
const priorityThemeIds = ['system', 'light', 'dark'];
|
||||
themePresets.sort((a, b) => {
|
||||
const pa = priorityThemeIds.indexOf(a.id);
|
||||
const pb = priorityThemeIds.indexOf(b.id);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const accentPresets = [
|
||||
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
|
||||
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' },
|
||||
@@ -583,6 +594,7 @@
|
||||
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
|
||||
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
|
||||
let activeFontSize = $state($appConfig?.font_size ?? 14);
|
||||
let scrollToChangeFontSize = $state($appConfig?.scroll_to_change_font_size ?? true);
|
||||
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
|
||||
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
|
||||
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
|
||||
@@ -987,6 +999,13 @@
|
||||
setFontSize(size).catch((e) => console.error('Failed to save font size:', e));
|
||||
}
|
||||
|
||||
function toggleScrollToChangeFontSize() {
|
||||
scrollToChangeFontSize = !scrollToChangeFontSize;
|
||||
if ($appConfig) $appConfig.scroll_to_change_font_size = scrollToChangeFontSize;
|
||||
setScrollToChangeFontSize(scrollToChangeFontSize)
|
||||
.catch((e) => console.error('Failed to save scroll font size setting:', e));
|
||||
}
|
||||
|
||||
function applyFontSize(size: number) {
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${size}px`);
|
||||
}
|
||||
@@ -1060,6 +1079,15 @@
|
||||
if (event.target === event.currentTarget) close();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (restoreConfirm) restoreConfirm = null;
|
||||
else if (customThemeEditorOpen) cancelCustomThemeEditor();
|
||||
else close();
|
||||
}
|
||||
|
||||
function dismissRestoreConfirm(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||
}
|
||||
@@ -1080,6 +1108,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
scrollToChangeFontSize = $appConfig?.scroll_to_change_font_size ?? true;
|
||||
const savedSize = $appConfig?.font_size;
|
||||
if (savedSize) {
|
||||
activeFontSize = savedSize;
|
||||
@@ -1139,7 +1168,7 @@
|
||||
|
||||
{#if $showSettings}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
|
||||
<div class="settings-overlay" class:mobile={isMobile} onclick={closeSettingsFromOverlay} onkeydowncapture={handleEscape} onkeydown={handleEscape}>
|
||||
<div class="settings-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||
<div class="settings-header">
|
||||
<h2 id="settings-title">Settings</h2>
|
||||
@@ -1804,7 +1833,7 @@
|
||||
<!-- Custom Theme Editor Modal -->
|
||||
{#if customThemeEditorOpen && customThemeEditing}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
|
||||
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeFromOverlay} onkeydown={handleEscape}>
|
||||
<div class="custom-theme-modal" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||
<div class="custom-theme-modal-header">
|
||||
<h3 id="custom-theme-title">{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
|
||||
@@ -1910,6 +1939,17 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !isMobile}
|
||||
<label class="setting-toggle">
|
||||
<span class="setting-label">
|
||||
<span class="setting-name">Scroll to change font size</span>
|
||||
<span class="setting-desc">Allow Cmd/Ctrl + scroll to resize editor text</span>
|
||||
</span>
|
||||
<button class="toggle-switch" class:on={scrollToChangeFontSize} role="switch" aria-checked={scrollToChangeFontSize} aria-label="Scroll to change font size" onclick={toggleScrollToChangeFontSize}>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isMobile}
|
||||
@@ -2101,7 +2141,7 @@
|
||||
|
||||
{#if restoreConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={(e) => { if (e.key === 'Escape') restoreConfirm = null; }}>
|
||||
<div class="restore-confirm-overlay" onclick={dismissRestoreConfirm} onkeydown={handleEscape}>
|
||||
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
||||
<p>This will replace all notes in your vault with the backup from <strong>{formatBackupDate(restoreConfirm.created)}</strong>. This action cannot be undone.</p>
|
||||
|
||||
@@ -700,7 +700,7 @@
|
||||
|
||||
<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}>
|
||||
<aside class="sidebar" class:collapsed={!isMobile && $sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -721,7 +721,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$sidebarCollapsed}
|
||||
{#if isMobile || !$sidebarCollapsed}
|
||||
{#if anyNavItem}
|
||||
<nav class="sidebar-nav">
|
||||
{#if $appConfig?.show_all_notes !== false}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export type HiddenTitleHeading = {
|
||||
headingPrefix: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type TitleHeadingResult = {
|
||||
markdown: string;
|
||||
hiddenTitle: HiddenTitleHeading | null;
|
||||
};
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[\s\-_\u2014]+/g, ' ');
|
||||
}
|
||||
|
||||
export function stripTitleHeading(
|
||||
markdown: string,
|
||||
title: string | undefined,
|
||||
hideTitle: boolean,
|
||||
): TitleHeadingResult {
|
||||
if (!hideTitle || !title) return { markdown, hiddenTitle: null };
|
||||
|
||||
const lines = markdown.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match && normalizeTitle(match[2]) === normalizeTitle(title)) {
|
||||
const hiddenTitle = { headingPrefix: match[1], title: title.trim() };
|
||||
lines.splice(i, 1);
|
||||
if (i < lines.length && lines[i].trim() === '') lines.splice(i, 1);
|
||||
return { markdown: lines.join('\n'), hiddenTitle };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { markdown, hiddenTitle: null };
|
||||
}
|
||||
|
||||
export function restoreTitleHeading(
|
||||
markdown: string,
|
||||
hiddenTitle: HiddenTitleHeading | null,
|
||||
): string {
|
||||
if (!hiddenTitle) return markdown;
|
||||
return `${hiddenTitle.headingPrefix} ${hiddenTitle.title}\n\n${markdown}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TextSelection, type EditorState, type Transaction } from '@tiptap/pm/state';
|
||||
|
||||
export type WikiLinkAttributes = {
|
||||
title: string;
|
||||
path: string;
|
||||
aliased: boolean;
|
||||
};
|
||||
|
||||
export function replaceWithWikiLink(
|
||||
state: EditorState,
|
||||
from: number,
|
||||
to: number,
|
||||
text: string,
|
||||
attrs: WikiLinkAttributes
|
||||
): Transaction {
|
||||
const wikiLinkMark = state.schema.marks.wikiLink.create(attrs);
|
||||
const textNode = state.schema.text(text, [wikiLinkMark]);
|
||||
const transaction = state.tr.replaceWith(from, to, textNode);
|
||||
transaction.setSelection(TextSelection.create(transaction.doc, from + text.length));
|
||||
transaction.setStoredMarks([]);
|
||||
return transaction;
|
||||
}
|
||||
@@ -92,6 +92,7 @@ export interface AppConfig {
|
||||
system_dark_theme: string;
|
||||
accent_color: string | null;
|
||||
font_size: number | null;
|
||||
scroll_to_change_font_size: boolean;
|
||||
font_family: string | null;
|
||||
line_height: number | null;
|
||||
ui_scale: number | null;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
const { getWheelFontSizeAction } = await import(
|
||||
new URL('./editor-zoom.ts', import.meta.url)
|
||||
);
|
||||
|
||||
test('blocks modified wheel input when scroll font sizing is disabled', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, false),
|
||||
'block'
|
||||
);
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, false),
|
||||
'block'
|
||||
);
|
||||
});
|
||||
|
||||
test('changes font size from modified wheel input when enabled', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: true, deltaY: -1 }, true),
|
||||
'increase'
|
||||
);
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: true, metaKey: false, deltaY: 1 }, true),
|
||||
'decrease'
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores unmodified wheel input', () => {
|
||||
assert.equal(
|
||||
getWheelFontSizeAction({ ctrlKey: false, metaKey: false, deltaY: -1 }, false),
|
||||
'ignore'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export type WheelFontSizeAction = 'ignore' | 'block' | 'increase' | 'decrease';
|
||||
|
||||
interface WheelModifierInput {
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
deltaY: number;
|
||||
}
|
||||
|
||||
export function getWheelFontSizeAction(
|
||||
event: WheelModifierInput,
|
||||
scrollToChangeFontSize: boolean,
|
||||
): WheelFontSizeAction {
|
||||
if (!event.ctrlKey && !event.metaKey) return 'ignore';
|
||||
if (!scrollToChangeFontSize) return 'block';
|
||||
return event.deltaY < 0 ? 'increase' : 'decrease';
|
||||
}
|
||||
+13
-2
@@ -3,6 +3,7 @@
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
||||
import { getWheelFontSizeAction } from '$lib/utils/editor-zoom';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@@ -16,6 +17,7 @@
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
let removeFontSizeListener: (() => void) | null = null;
|
||||
let removeScrollFontSizeListener: (() => void) | null = null;
|
||||
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startupWindowRevealed = false;
|
||||
|
||||
@@ -121,10 +123,15 @@
|
||||
};
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
const action = getWheelFontSizeAction(
|
||||
event,
|
||||
$appConfig?.scroll_to_change_font_size ?? true
|
||||
);
|
||||
if (action === 'ignore') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomEditor(event.deltaY < 0 ? 1 : -1);
|
||||
if (action === 'increase') zoomEditor(1);
|
||||
else if (action === 'decrease') zoomEditor(-1);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
@@ -153,6 +160,9 @@
|
||||
removeFontSizeListener = await listen<number>('editor-font-size-changed', (event) => {
|
||||
if ($appConfig?.font_size !== event.payload) applyEditorFontSize(event.payload);
|
||||
});
|
||||
removeScrollFontSizeListener = await listen<boolean>('scroll-to-change-font-size-changed', (event) => {
|
||||
if ($appConfig) $appConfig.scroll_to_change_font_size = event.payload;
|
||||
});
|
||||
$theme = config.theme || 'system';
|
||||
|
||||
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||
@@ -287,6 +297,7 @@
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
removeFontSizeListener?.();
|
||||
removeScrollFontSizeListener?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { createContext, Script } from 'node:vm';
|
||||
import { compile, parse } from 'svelte/compiler';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/Sidebar.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const ast = parse(source, { modern: true });
|
||||
|
||||
function elementWithClass(nodes, className) {
|
||||
return nodes.find((node) => node.attributes?.some((attribute) =>
|
||||
attribute.name === 'class' && attribute.value[0]?.data === className
|
||||
));
|
||||
}
|
||||
|
||||
const aside = elementWithClass(ast.fragment.nodes, 'sidebar');
|
||||
const blocks = aside.fragment.nodes.filter((node) => node.type === 'IfBlock');
|
||||
const content = blocks.find((node) => elementWithClass(node.consequent.nodes, 'section'));
|
||||
const header = blocks.find((node) => elementWithClass(node.consequent.nodes, 'sidebar-header'));
|
||||
const toggle = elementWithClass(
|
||||
elementWithClass(header.consequent.nodes, 'sidebar-header').fragment.nodes,
|
||||
'collapse-btn'
|
||||
);
|
||||
|
||||
function expressionScript(expression) {
|
||||
assert.ok(expression, 'expected a component expression');
|
||||
return new Script(`(${source.slice(expression.start, expression.end)})`);
|
||||
}
|
||||
|
||||
// Execute the actual template conditions, not a copy of the collapse logic.
|
||||
const expressions = {
|
||||
contentVisible: expressionScript(content.test),
|
||||
collapsedClass: expressionScript(aside.attributes.find((attribute) =>
|
||||
attribute.type === 'ClassDirective' && attribute.name === 'collapsed'
|
||||
).expression),
|
||||
toggleVisible: expressionScript(header.test)
|
||||
};
|
||||
const toggleScript = expressionScript(
|
||||
toggle.attributes.find((attribute) => attribute.name === 'onclick').value.expression
|
||||
);
|
||||
|
||||
function sidebarContext(isMobile, stored) {
|
||||
const sidebarCollapsed = writable(stored);
|
||||
const context = createContext({
|
||||
isMobile,
|
||||
get $sidebarCollapsed() { return get(sidebarCollapsed); },
|
||||
set $sidebarCollapsed(value) { sidebarCollapsed.set(value); }
|
||||
});
|
||||
return { context, sidebarCollapsed };
|
||||
}
|
||||
|
||||
function evaluate(context) {
|
||||
return Object.fromEntries(Object.entries(expressions).map(([name, script]) =>
|
||||
[name, script.runInContext(context)]
|
||||
));
|
||||
}
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
platform: 'desktop', isMobile: false,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: true },
|
||||
collapsed: { contentVisible: false, collapsedClass: true, toggleVisible: true }
|
||||
},
|
||||
{
|
||||
platform: 'mobile', isMobile: true,
|
||||
expanded: { contentVisible: true, collapsedClass: false, toggleVisible: false },
|
||||
collapsed: { contentVisible: true, collapsedClass: false, toggleVisible: false }
|
||||
}
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
for (const stored of [false, true]) {
|
||||
test(`${scenario.platform} navigation with stored sidebar_collapsed=${stored}`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, stored);
|
||||
|
||||
assert.deepEqual(evaluate(context), stored ? scenario.collapsed : scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), stored, 'rendering must preserve the stored preference');
|
||||
});
|
||||
}
|
||||
|
||||
test(`${scenario.platform} navigation follows late false -> true -> false store updates`, () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(scenario.isMobile, false);
|
||||
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
sidebarCollapsed.set(true);
|
||||
assert.deepEqual(evaluate(context), scenario.collapsed);
|
||||
assert.equal(get(sidebarCollapsed), true, 'rendering must not reset the loaded preference');
|
||||
sidebarCollapsed.set(false);
|
||||
assert.deepEqual(evaluate(context), scenario.expanded);
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('desktop collapse toggle still collapses and expands the sidebar', () => {
|
||||
const { context, sidebarCollapsed } = sidebarContext(false, false);
|
||||
const toggleSidebar = toggleScript.runInContext(context);
|
||||
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), true);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].collapsed);
|
||||
toggleSidebar();
|
||||
assert.equal(get(sidebarCollapsed), false);
|
||||
assert.deepEqual(evaluate(context), scenarios[0].expanded);
|
||||
});
|
||||
|
||||
test('Sidebar compiles for the client', () => {
|
||||
assert.doesNotThrow(() => compile(source, {
|
||||
filename: 'src/lib/components/Sidebar.svelte',
|
||||
generate: 'client'
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = await readFile(
|
||||
new URL('../src/lib/components/SettingsPanel.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const initialization = source.match(
|
||||
/(const\s+themePresets\s*=\s*\[[\s\S]*?\];)[\s\S]*?(?=\bconst\s+accentPresets\s*=)/
|
||||
);
|
||||
assert.ok(initialization, 'theme presets initialization was not found');
|
||||
|
||||
// Evaluate only the trusted repository declaration and its initialization code.
|
||||
const unsorted = new Function(`${initialization[1]}\nreturn themePresets;`)();
|
||||
const presets = new Function(`${initialization[0]}\nreturn themePresets;`)();
|
||||
|
||||
test('pins System, Light, and Dark in that exact order', () => {
|
||||
assert.deepEqual(presets.slice(0, 3).map(({ id, label }) => ({ id, label })), [
|
||||
{ id: 'system', label: 'System' },
|
||||
{ id: 'light', label: 'Light' },
|
||||
{ id: 'dark', label: 'Dark' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('orders remaining built-in labels alphabetically, including Rosé Pine', () => {
|
||||
const labels = presets.slice(3).map((preset) => preset.label);
|
||||
assert.ok(labels.includes('Rosé Pine'));
|
||||
assert.deepEqual(labels, [...labels].sort());
|
||||
});
|
||||
|
||||
test('preserves every theme ID, label, and color without duplicates', () => {
|
||||
assert.equal(presets.length, unsorted.length);
|
||||
for (const themes of [unsorted, presets]) {
|
||||
assert.equal(new Set(themes.map((preset) => preset.id)).size, themes.length);
|
||||
assert.equal(new Set(themes.map((preset) => preset.label)).size, themes.length);
|
||||
}
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(presets.map((preset) => [preset.id, preset])),
|
||||
Object.fromEntries(unsorted.map((preset) => [preset.id, preset]))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const { restoreTitleHeading, stripTitleHeading } = await import(
|
||||
new URL('../src/lib/editor/titleVisibility.ts', import.meta.url)
|
||||
);
|
||||
const editorSource = await readFile(
|
||||
new URL('../src/lib/components/Editor.svelte', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
test('rich to source transition strips the restored title before display', () => {
|
||||
const start = editorSource.indexOf('if (isSource && !lastSourceMode) {');
|
||||
const end = editorSource.indexOf('} else if (!isSource && lastSourceMode)', start);
|
||||
|
||||
assert.notEqual(start, -1, 'rich to source transition was not found');
|
||||
assert.notEqual(end, -1, 'rich to source transition boundary was not found');
|
||||
assert.match(
|
||||
editorSource.slice(start, end),
|
||||
/sourceContent\s*=\s*stripTitleH1\(\s*editor\s*\?\s*editorToMarkdown\(\)\s*:\s*\(\$activeNote\?\.content\s*\?\?\s*''\)\s*\)\s*;/
|
||||
);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves the hidden title on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
|
||||
assert.notEqual(start, -1, 'source to rich transition was not found');
|
||||
assert.notEqual(end, -1, 'source to rich transition boundary was not found');
|
||||
assert.match(
|
||||
transition,
|
||||
/editor\.commands\.setContent\(\s*markdownToHtml\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*\)\s*;/
|
||||
);
|
||||
assert.match(transition, /createEditor\(\s*restoreTitleH1\(\s*content\s*\)\s*\)\s*;/);
|
||||
});
|
||||
|
||||
test('source to rich transition preserves an empty source body on desktop and mobile', () => {
|
||||
const start = editorSource.indexOf('} else if (!isSource && lastSourceMode) {');
|
||||
const end = editorSource.indexOf('// Tauri drag-drop listener', start);
|
||||
const transition = editorSource.slice(start, end);
|
||||
const contentAssignments = [...transition.matchAll(/const content = ([^;]+);/g)]
|
||||
.map((match) => match[1].trim());
|
||||
|
||||
assert.deepEqual(contentAssignments, ['srcText', 'srcText']);
|
||||
assert.doesNotMatch(transition, /srcText\s*\|\|/);
|
||||
});
|
||||
|
||||
test('keeps a hidden title through source to rich to source and save', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n\nBody\n';
|
||||
const body = '## Something else\n\nBody\n';
|
||||
|
||||
const initialSource = stripTitleHeading(persisted, 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const toggledSource = stripTitleHeading(
|
||||
restoreTitleHeading(rich.markdown, rich.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(rich.markdown, body);
|
||||
assert.equal(toggledSource.markdown, body);
|
||||
assert.equal(restoreTitleHeading(toggledSource.markdown, toggledSource.hiddenTitle), persisted);
|
||||
});
|
||||
|
||||
test('keeps a title-only note visually empty and saves one title heading', () => {
|
||||
const initialSource = stripTitleHeading('# Note title\n', 'Note title', true);
|
||||
const rich = stripTitleHeading(
|
||||
restoreTitleHeading(initialSource.markdown, initialSource.hiddenTitle),
|
||||
'Note title',
|
||||
true
|
||||
);
|
||||
const saved = restoreTitleHeading(rich.markdown, rich.hiddenTitle);
|
||||
|
||||
assert.equal(initialSource.markdown, '');
|
||||
assert.equal(rich.markdown, '');
|
||||
assert.equal((saved.match(/^# Note title$/gm) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('replaces hidden title state when an unrelated note loads', () => {
|
||||
const markdown = '## Something else\n\nOther body\n';
|
||||
const start = editorSource.indexOf('function stripTitleH1(md: string): string {');
|
||||
const end = editorSource.indexOf('function restoreTitleH1(md: string): string {', start);
|
||||
|
||||
assert.deepEqual(stripTitleHeading(markdown, 'Another note', true), {
|
||||
markdown,
|
||||
hiddenTitle: null
|
||||
});
|
||||
assert.match(editorSource.slice(start, end), /hiddenTitleHeading\s*=\s*result\.hiddenTitle\s*;/);
|
||||
});
|
||||
|
||||
test('leaves the title visible when title hiding is disabled', () => {
|
||||
const persisted = '# Note title\n\n## Something else\n';
|
||||
|
||||
assert.deepEqual(stripTitleHeading(persisted, 'Note title', false), {
|
||||
markdown: persisted,
|
||||
hiddenTitle: null
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user