mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
8
Commits
v1.3.5
...
9d92489586
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c | ||
|
|
d7e4be7f3a | ||
|
|
2aa572dcaa | ||
|
|
32f270ce9e |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1068,6 +1068,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 +1157,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 +1822,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 +2130,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>
|
||||
|
||||
@@ -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,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