mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d92489586 | ||
|
|
c45efb60ab | ||
|
|
0d11fd01dd | ||
|
|
0aaa05ce5c | ||
|
|
77e92ef25c |
@@ -578,6 +578,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
const dismissesAppUi = $showSettings || $showInfo || $focusMode || $showSearch || $showCommandPalette;
|
||||||
|
if (!dismissesAppUi) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
else if ($focusMode) $focusMode = 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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'ArrowDown') {
|
||||||
$showCommandPalette = false;
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
@@ -194,7 +199,7 @@
|
|||||||
|
|
||||||
{#if $showCommandPalette}
|
{#if $showCommandPalette}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
<div class="palette-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
<div class="palette-input-wrapper">
|
<div class="palette-input-wrapper">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
import { clearFormatting } from '$lib/editor/clearFormatting';
|
import { clearFormatting } from '$lib/editor/clearFormatting';
|
||||||
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||||
|
import { restoreTitleHeading, stripTitleHeading, type HiddenTitleHeading } from '$lib/editor/titleVisibility';
|
||||||
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
import { replaceWithWikiLink } from '$lib/editor/wikiLinks';
|
||||||
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
@@ -117,9 +118,7 @@
|
|||||||
let hasPendingBlobs = false;
|
let hasPendingBlobs = false;
|
||||||
let lastSourceMode = $sourceMode;
|
let lastSourceMode = $sourceMode;
|
||||||
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null);
|
||||||
let titleWasStripped = false;
|
let hiddenTitleHeading: HiddenTitleHeading | null = null;
|
||||||
let strippedTitle = '';
|
|
||||||
let strippedHeadingPrefix = '';
|
|
||||||
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
let taskRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let taskRevealElement: HTMLElement | null = null;
|
let taskRevealElement: HTMLElement | null = null;
|
||||||
let taskRevealRequest = 0;
|
let taskRevealRequest = 0;
|
||||||
@@ -3403,44 +3402,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stripTitleH1(md: string): string {
|
function stripTitleH1(md: string): string {
|
||||||
const title = $activeNote?.meta.title;
|
const result = stripTitleHeading(md, $activeNote?.meta.title, $appConfig?.hide_title_in_body ?? false);
|
||||||
if (!$appConfig?.hide_title_in_body || !title) {
|
hiddenTitleHeading = result.hiddenTitle;
|
||||||
titleWasStripped = false;
|
return result.markdown;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreTitleH1(md: string): string {
|
function restoreTitleH1(md: string): string {
|
||||||
if (!titleWasStripped || !strippedTitle) return md;
|
return restoreTitleHeading(md, hiddenTitleHeading);
|
||||||
return `${strippedHeadingPrefix} ${strippedTitle}\n\n${md}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editorToMarkdown(): string {
|
function editorToMarkdown(): string {
|
||||||
@@ -4996,13 +4964,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ctxPaste() {
|
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 {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
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) {
|
} catch (e) {
|
||||||
console.error('Paste failed:', 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();
|
closeTextContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5782,7 +5762,7 @@
|
|||||||
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
? editor.state.doc.textBetween(0, editor.state.selection.from, '\n', '').replace(/\s/g, '').length
|
||||||
: 0;
|
: 0;
|
||||||
const docNonWs = docNonWhitespace();
|
const docNonWs = docNonWhitespace();
|
||||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
sourceContent = stripTitleH1(editor ? editorToMarkdown() : ($activeNote?.content ?? ''));
|
||||||
resetSourceHistory(sourceContent);
|
resetSourceHistory(sourceContent);
|
||||||
lastSourceMode = true;
|
lastSourceMode = true;
|
||||||
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
const target = caretNonWs > 0 ? scanAlign(sourceContent, docNonWs, { stopAtNw: caretNonWs }).srcOffset : 0;
|
||||||
@@ -5809,20 +5789,20 @@
|
|||||||
};
|
};
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Mobile: editor stays in DOM, just update its content
|
// Mobile: editor stays in DOM, just update its content
|
||||||
const content = srcText || ($activeNote?.content ?? '');
|
const content = srcText;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
ignoreNextUpdate = true;
|
ignoreNextUpdate = true;
|
||||||
editor.commands.setContent(markdownToHtml(content));
|
editor.commands.setContent(markdownToHtml(restoreTitleH1(content)));
|
||||||
tick().then(restoreRichCaret);
|
tick().then(restoreRichCaret);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Desktop: destroy old editor (its DOM element is gone),
|
// Desktop: destroy old editor (its DOM element is gone),
|
||||||
// wait for DOM to swap textarea→div, then create editor on new element.
|
// wait for DOM to swap textarea→div, then create editor on new element.
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
const content = srcText || ($activeNote?.content ?? '');
|
const content = srcText;
|
||||||
tick().then(() => {
|
tick().then(() => {
|
||||||
if (editorElement && !editor) {
|
if (editorElement && !editor) {
|
||||||
createEditor(content);
|
createEditor(restoreTitleH1(content));
|
||||||
restoreRichCaret();
|
restoreRichCaret();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -5955,7 +5935,7 @@
|
|||||||
const oldPath = $activeNotePath;
|
const oldPath = $activeNotePath;
|
||||||
$activeNote.meta.title = newTitle;
|
$activeNote.meta.title = newTitle;
|
||||||
// Update stripped title so restoreTitleH1 uses the new title
|
// Update stripped title so restoreTitleH1 uses the new title
|
||||||
if (titleWasStripped) strippedTitle = newTitle;
|
if (hiddenTitleHeading) hiddenTitleHeading = { ...hiddenTitleHeading, title: newTitle };
|
||||||
$editorDirty = true;
|
$editorDirty = true;
|
||||||
// Force save current editor content before renaming so disk is up-to-date
|
// Force save current editor content before renaming so disk is up-to-date
|
||||||
await forceSave();
|
await forceSave();
|
||||||
|
|||||||
@@ -95,6 +95,13 @@
|
|||||||
if (event.target === event.currentTarget) close();
|
if (event.target === event.currentTarget) close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
function openLink(url: string) {
|
function openLink(url: string) {
|
||||||
openUrl(url).catch(console.error);
|
openUrl(url).catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -104,7 +111,7 @@
|
|||||||
|
|
||||||
{#if $showInfo}
|
{#if $showInfo}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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-panel">
|
||||||
<div class="info-header">
|
<div class="info-header">
|
||||||
<h2>Info</h2>
|
<h2>Info</h2>
|
||||||
|
|||||||
@@ -44,10 +44,15 @@
|
|||||||
if (item) item.scrollIntoView({ block: 'nearest' });
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'ArrowDown') {
|
||||||
$showSearch = false;
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||||
scrollToSelected();
|
scrollToSelected();
|
||||||
@@ -128,7 +133,7 @@
|
|||||||
|
|
||||||
{#if $showSearch}
|
{#if $showSearch}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
<div class="search-panel" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||||
<div class="search-input-wrapper">
|
<div class="search-input-wrapper">
|
||||||
|
|||||||
@@ -1068,6 +1068,15 @@
|
|||||||
if (event.target === event.currentTarget) close();
|
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) {
|
function dismissRestoreConfirm(event: MouseEvent) {
|
||||||
if (event.target === event.currentTarget) restoreConfirm = null;
|
if (event.target === event.currentTarget) restoreConfirm = null;
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1157,7 @@
|
|||||||
|
|
||||||
{#if $showSettings}
|
{#if $showSettings}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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-panel" class:mobile={isMobile} role="dialog" aria-modal="true" aria-labelledby="settings-title" tabindex="-1">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2 id="settings-title">Settings</h2>
|
<h2 id="settings-title">Settings</h2>
|
||||||
@@ -1813,7 +1822,7 @@
|
|||||||
<!-- Custom Theme Editor Modal -->
|
<!-- Custom Theme Editor Modal -->
|
||||||
{#if customThemeEditorOpen && customThemeEditing}
|
{#if customThemeEditorOpen && customThemeEditing}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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" role="dialog" aria-modal="true" aria-labelledby="custom-theme-title" tabindex="-1">
|
||||||
<div class="custom-theme-modal-header">
|
<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>
|
<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}
|
{#if restoreConfirm}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- 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">
|
<div class="restore-confirm" role="alertdialog" aria-modal="true" aria-labelledby="restore-confirm-title" tabindex="-1">
|
||||||
<h4 id="restore-confirm-title">Restore Backup?</h4>
|
<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>
|
<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