Compare commits

..
15 Commits
Author SHA1 Message Date
Yuri Karamian 2983e3db29 Merge branch 'main' into 'main'
Merge branch 'fix/sort-theme-list-alphabetically' into 'main'

Closes #250

See merge request ArkHost/HelixNotes!7
2026-09-17 12:30:52 +00:00
Shawn cc7c9e2b49 Merge branch HelixNotes:main into main 2026-09-15 16:09:56 +00:00
Shawn 2240231f61 Merge branch 'fix/sort-theme-list-alphabetically' into 'main'
fix: sort theme list alphabetically

See merge request locodarwin/HelixNotes!1
2026-09-15 15:36:44 +00:00
Yuri Karamian b2d0c7175e test: cover built-in theme preset ordering 2026-09-15 15:04:51 +02:00
Shawn Olson 1bc0026731 fix: sort theme list alphabetically 2026-09-14 14:42:06 -07:00
Yuri Karamian 41c0e4c993 fix: preserve Linux window title and configuration 2026-09-10 16:50:54 +02:00
Yuri Karamian 34a0f9efa3 fix: keep mobile navigation visible with collapsed desktop sidebar 2026-09-06 22:46:11 +02:00
Yuri Karamian 9d92489586 Merge branch 'fix/macos-escape-overlay-fullscreen' into 'main'
Prevent Escape from exiting fullscreen when closing overlays

See merge request ArkHost/HelixNotes!4
2026-09-04 13:00:47 +00:00
Vijay c45efb60ab Prevent Escape from exiting fullscreen when closing overlays 2026-09-04 13:00:46 +00:00
Yuri Karamian 0d11fd01dd fix: preserve hidden note title across editor modes 2026-09-04 14:46:30 +02:00
Yuri Karamian 0aaa05ce5c Merge branch 'fix/context-menu-paste-image' into 'main'
fix: paste images from the editor context menu

See merge request ArkHost/HelixNotes!6
2026-09-04 08:54:04 +00:00
Sridhar 77e92ef25c fix: paste images from the editor context menu 2026-09-04 08:54:04 +00:00
Yuri Karamian d7e4be7f3a Merge branch 'fix/webkitgtk-clipboard-image-paste' into 'main'
fix: paste images copied from a browser on WebKitGTK

Closes #245

See merge request ArkHost/HelixNotes!5
2026-09-03 19:07:34 +00:00
Sridhar 2aa572dcaa fix: paste images copied from a browser on WebKitGTK 2026-09-03 19:07:34 +00:00
Yuri Karamian 32f270ce9e Update v1.3.5 download documentation 2026-08-27 00:37:30 +02:00
13 changed files with 426 additions and 84 deletions
+15 -13
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.4-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.4)
[![Latest Release](https://img.shields.io/badge/release-v1.3.5-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.5)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -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)
---
+8
View File
@@ -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
}
]
+5
View File
@@ -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;
+9 -4
View File
@@ -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">
+40 -57
View File
@@ -54,6 +54,7 @@
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';
@@ -117,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;
@@ -3403,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 {
@@ -4996,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();
}
@@ -5629,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;
}
@@ -5779,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;
@@ -5806,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();
}
});
@@ -5952,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();
+8 -1
View File
@@ -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>
+9 -4
View File
@@ -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">
+23 -3
View File
@@ -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' },
@@ -1068,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;
}
@@ -1148,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>
@@ -1813,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>
@@ -2121,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>
+2 -2
View File
@@ -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}
+46
View File
@@ -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}`;
}
+115
View File
@@ -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'
}));
});
+42
View File
@@ -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]))
);
});
+104
View File
@@ -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
});
});