mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-23 23:35:57 +02:00
v1.1.2 - Alt+Arrow move lines, Ctrl+Shift+M source toggle, code syntax highlighting, outline panel, image spacing fix, daily notes
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.1.1"
|
version = "1.1.2"
|
||||||
description = "Local markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ pub fn set_general_settings(
|
|||||||
pdf_height: u32,
|
pdf_height: u32,
|
||||||
title_mode: String,
|
title_mode: String,
|
||||||
hide_title_in_body: bool,
|
hide_title_in_body: bool,
|
||||||
|
show_line_numbers: bool,
|
||||||
default_view_mode: bool,
|
default_view_mode: bool,
|
||||||
show_tray_icon: bool,
|
show_tray_icon: bool,
|
||||||
close_to_tray: bool,
|
close_to_tray: bool,
|
||||||
@@ -521,6 +522,7 @@ pub fn set_general_settings(
|
|||||||
config.pdf_height = pdf_height;
|
config.pdf_height = pdf_height;
|
||||||
config.title_mode = title_mode;
|
config.title_mode = title_mode;
|
||||||
config.hide_title_in_body = hide_title_in_body;
|
config.hide_title_in_body = hide_title_in_body;
|
||||||
|
config.show_line_numbers = show_line_numbers;
|
||||||
config.default_view_mode = default_view_mode;
|
config.default_view_mode = default_view_mode;
|
||||||
config.show_tray_icon = show_tray_icon;
|
config.show_tray_icon = show_tray_icon;
|
||||||
config.close_to_tray = close_to_tray;
|
config.close_to_tray = close_to_tray;
|
||||||
@@ -1478,8 +1480,6 @@ pub fn get_install_type() -> String {
|
|||||||
"macos".to_string()
|
"macos".to_string()
|
||||||
} else if cfg!(target_os = "windows") {
|
} else if cfg!(target_os = "windows") {
|
||||||
"windows".to_string()
|
"windows".to_string()
|
||||||
} else if std::env::var("APPIMAGE").is_ok() {
|
|
||||||
"appimage".to_string()
|
|
||||||
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
|
||||||
"deb".to_string()
|
"deb".to_string()
|
||||||
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
} else if std::path::Path::new("/var/lib/pacman/local").exists()
|
||||||
@@ -1490,6 +1490,8 @@ pub fn get_install_type() -> String {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
"aur".to_string()
|
"aur".to_string()
|
||||||
|
} else if std::env::var("APPIMAGE").is_ok() {
|
||||||
|
"appimage".to_string()
|
||||||
} else {
|
} else {
|
||||||
"native".to_string()
|
"native".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_title_in_body: bool,
|
pub hide_title_in_body: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub show_line_numbers: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub backup_enabled: bool,
|
pub backup_enabled: bool,
|
||||||
#[serde(default = "default_backup_frequency")]
|
#[serde(default = "default_backup_frequency")]
|
||||||
pub backup_frequency: String,
|
pub backup_frequency: String,
|
||||||
@@ -148,6 +150,7 @@ impl Default for AppConfig {
|
|||||||
pdf_height: 600,
|
pdf_height: 600,
|
||||||
title_mode: "input".to_string(),
|
title_mode: "input".to_string(),
|
||||||
hide_title_in_body: false,
|
hide_title_in_body: false,
|
||||||
|
show_line_numbers: false,
|
||||||
backup_enabled: false,
|
backup_enabled: false,
|
||||||
backup_frequency: "24h".to_string(),
|
backup_frequency: "24h".to_string(),
|
||||||
backup_max_count: 10,
|
backup_max_count: 10,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "HelixNotes",
|
"productName": "HelixNotes",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"identifier": "com.helixnotes.app",
|
"identifier": "com.helixnotes.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../build",
|
"frontendDist": "../build",
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export async function setGeneralSettings(
|
|||||||
pdfHeight: number,
|
pdfHeight: number,
|
||||||
titleMode: string,
|
titleMode: string,
|
||||||
hideTitleInBody: boolean,
|
hideTitleInBody: boolean,
|
||||||
|
showLineNumbers: boolean,
|
||||||
defaultViewMode: boolean,
|
defaultViewMode: boolean,
|
||||||
showTrayIcon: boolean,
|
showTrayIcon: boolean,
|
||||||
closeToTray: boolean,
|
closeToTray: boolean,
|
||||||
@@ -205,6 +206,7 @@ export async function setGeneralSettings(
|
|||||||
pdfHeight,
|
pdfHeight,
|
||||||
titleMode,
|
titleMode,
|
||||||
hideTitleInBody,
|
hideTitleInBody,
|
||||||
|
showLineNumbers,
|
||||||
defaultViewMode,
|
defaultViewMode,
|
||||||
showTrayIcon,
|
showTrayIcon,
|
||||||
closeToTray,
|
closeToTray,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
activeNotePath,
|
activeNotePath,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
showInfo,
|
showInfo,
|
||||||
showSettings
|
showSettings,
|
||||||
|
sourceMode
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
@@ -169,6 +170,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.forceSave();
|
editor?.forceSave();
|
||||||
}
|
}
|
||||||
|
if (mod && e.shiftKey && e.key === 'M') {
|
||||||
|
e.preventDefault();
|
||||||
|
$sourceMode = !$sourceMode;
|
||||||
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
|
|||||||
@@ -99,6 +99,29 @@
|
|||||||
|
|
||||||
// View mode (read-only) — state managed by $readOnly store
|
// View mode (read-only) — state managed by $readOnly store
|
||||||
|
|
||||||
|
// Outline
|
||||||
|
let showOutline = $state(false);
|
||||||
|
interface OutlineHeading { level: number; text: string; pos: number; }
|
||||||
|
let outlineHeadings = $state<OutlineHeading[]>([]);
|
||||||
|
|
||||||
|
function updateOutline() {
|
||||||
|
if (!editor) { outlineHeadings = []; return; }
|
||||||
|
const headings: OutlineHeading[] = [];
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'heading') {
|
||||||
|
headings.push({ level: node.attrs.level, text: node.textContent, pos });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outlineHeadings = headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHeading(pos: number) {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.commands.setTextSelection(pos + 1);
|
||||||
|
editor.commands.scrollIntoView();
|
||||||
|
editor.view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// Version history
|
// Version history
|
||||||
let showHistory = $state(false);
|
let showHistory = $state(false);
|
||||||
let showGraph = $state(false);
|
let showGraph = $state(false);
|
||||||
@@ -377,6 +400,66 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MoveLineShortcuts = Extension.create({
|
||||||
|
name: 'moveLineShortcuts',
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('moveLineShortcuts'),
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown(view, event) {
|
||||||
|
if (!event.altKey || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
const { state, dispatch } = view;
|
||||||
|
const resolvedPos = state.selection.$from;
|
||||||
|
// Find the top-level block index
|
||||||
|
const depth = 1; // top-level blocks in doc
|
||||||
|
if (resolvedPos.depth < depth) return true;
|
||||||
|
const parentPos = resolvedPos.before(depth);
|
||||||
|
const parentNode = state.doc.nodeAt(parentPos);
|
||||||
|
if (!parentNode) return true;
|
||||||
|
const parentIndex = resolvedPos.index(0);
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
if (parentIndex <= 0) return true;
|
||||||
|
const prevPos = resolvedPos.posAtIndex(parentIndex - 1, 0);
|
||||||
|
const prevNode = state.doc.nodeAt(prevPos);
|
||||||
|
if (!prevNode) return true;
|
||||||
|
const tr = state.tr;
|
||||||
|
const cursorOffset = resolvedPos.pos - parentPos;
|
||||||
|
// Delete current block, insert it before previous block
|
||||||
|
const curSlice = state.doc.slice(parentPos, parentPos + parentNode.nodeSize);
|
||||||
|
tr.delete(parentPos, parentPos + parentNode.nodeSize);
|
||||||
|
const insertAt = tr.mapping.map(prevPos);
|
||||||
|
tr.insert(insertAt, curSlice.content);
|
||||||
|
const newCursorPos = Math.min(insertAt + cursorOffset, tr.doc.content.size);
|
||||||
|
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
|
||||||
|
dispatch(tr.scrollIntoView());
|
||||||
|
} else {
|
||||||
|
if (parentIndex >= state.doc.childCount - 1) return true;
|
||||||
|
const nextPos = resolvedPos.posAtIndex(parentIndex + 1, 0);
|
||||||
|
const nextNode = state.doc.nodeAt(nextPos);
|
||||||
|
if (!nextNode) return true;
|
||||||
|
const tr = state.tr;
|
||||||
|
const cursorOffset = resolvedPos.pos - parentPos;
|
||||||
|
// Delete next block, insert it before current block
|
||||||
|
const nextSlice = state.doc.slice(nextPos, nextPos + nextNode.nodeSize);
|
||||||
|
tr.delete(nextPos, nextPos + nextNode.nodeSize);
|
||||||
|
const insertAt = tr.mapping.map(parentPos);
|
||||||
|
tr.insert(insertAt, nextSlice.content);
|
||||||
|
const newCursorPos = Math.min(tr.mapping.map(parentPos) + cursorOffset, tr.doc.content.size);
|
||||||
|
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newCursorPos)));
|
||||||
|
dispatch(tr.scrollIntoView());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const CodeBlockLanguageSelect = Extension.create({
|
const CodeBlockLanguageSelect = Extension.create({
|
||||||
name: 'codeBlockLanguageSelect',
|
name: 'codeBlockLanguageSelect',
|
||||||
addGlobalAttributes() {
|
addGlobalAttributes() {
|
||||||
@@ -1177,9 +1260,21 @@
|
|||||||
return restoreTitleH1(md);
|
return restoreTitleH1(md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImageNode(node: any): boolean {
|
||||||
|
if (node.type.name === 'image') return true;
|
||||||
|
if (node.type.name !== 'paragraph') return false;
|
||||||
|
let hasImage = false;
|
||||||
|
let hasOther = false;
|
||||||
|
node.forEach((child: any) => {
|
||||||
|
if (child.type.name === 'image') hasImage = true;
|
||||||
|
else hasOther = true;
|
||||||
|
});
|
||||||
|
return hasImage && !hasOther;
|
||||||
|
}
|
||||||
|
|
||||||
function prosemirrorToMarkdown(doc: any): string {
|
function prosemirrorToMarkdown(doc: any): string {
|
||||||
const listTypes = new Set(['bulletList', 'orderedList', 'taskList']);
|
const listTypes = new Set(['bulletList', 'orderedList', 'taskList']);
|
||||||
const parts: string[] = [];
|
const entries: { text: string; isImage: boolean }[] = [];
|
||||||
let prevType = '';
|
let prevType = '';
|
||||||
let preserveEmptyParas = false;
|
let preserveEmptyParas = false;
|
||||||
doc.forEach((node: any) => {
|
doc.forEach((node: any) => {
|
||||||
@@ -1190,15 +1285,25 @@
|
|||||||
preserveEmptyParas = true;
|
preserveEmptyParas = true;
|
||||||
}
|
}
|
||||||
if (isEmpty && preserveEmptyParas) {
|
if (isEmpty && preserveEmptyParas) {
|
||||||
parts.push('<!-- -->');
|
entries.push({ text: '<!-- -->', isImage: false });
|
||||||
prevType = node.type.name;
|
prevType = node.type.name;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preserveEmptyParas = false;
|
preserveEmptyParas = false;
|
||||||
parts.push(serializeNode(node));
|
entries.push({ text: serializeNode(node), isImage: isImageNode(node) });
|
||||||
prevType = node.type.name;
|
prevType = node.type.name;
|
||||||
});
|
});
|
||||||
return parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
// Join: skip extra \n separator before image nodes so they don't get unwanted blank lines
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
result = entries[i].text;
|
||||||
|
} else {
|
||||||
|
const separator = entries[i].isImage ? '' : '\n';
|
||||||
|
result += separator + entries[i].text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeNode(node: any): string {
|
function serializeNode(node: any): string {
|
||||||
@@ -1282,7 +1387,7 @@
|
|||||||
const alt = node.attrs.alt || '';
|
const alt = node.attrs.alt || '';
|
||||||
const size = node.attrs['data-size'] || node.attrs.size || 'full';
|
const size = node.attrs['data-size'] || node.attrs.size || 'full';
|
||||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||||
return ``;
|
return `\n`;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return node.textContent || '';
|
return node.textContent || '';
|
||||||
@@ -1358,6 +1463,9 @@
|
|||||||
const alt = child.attrs.alt || '';
|
const alt = child.attrs.alt || '';
|
||||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||||
|
if (parts.length > 0 && parts[parts.length - 1] !== '\n') {
|
||||||
|
parts.push('\n');
|
||||||
|
}
|
||||||
parts.push(``);
|
parts.push(``);
|
||||||
} else if (child.type.name === 'mathInline') {
|
} else if (child.type.name === 'mathInline') {
|
||||||
parts.push(`$${child.attrs.tex || ''}$`);
|
parts.push(`$${child.attrs.tex || ''}$`);
|
||||||
@@ -1716,9 +1824,17 @@
|
|||||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
|
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- <tiptask checked="false">$2</tiptask>');
|
||||||
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1- <tiptask checked="false"> </tiptask>');
|
||||||
|
|
||||||
|
// Pre-process: preserve blank lines before image-only lines
|
||||||
|
// markdown-it collapses blank lines into paragraph breaks, losing the empty paragraph.
|
||||||
|
// Insert a <div> marker that markdown-it passes through (html: true), then convert to <p></p>
|
||||||
|
src = src.replace(/\n\n(!\[[^\]]*\]\([^)]*\)\s*$)/gm, '\n\n<div data-img-gap></div>\n\n$1');
|
||||||
|
|
||||||
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
|
// Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML)
|
||||||
let html = mdit.render(src);
|
let html = mdit.render(src);
|
||||||
|
|
||||||
|
// Post-process: convert image gap markers into empty paragraphs for ProseMirror
|
||||||
|
html = html.replace(/<div data-img-gap><\/div>\n?/g, '<p></p>\n');
|
||||||
|
|
||||||
// Post-process: strip trailing newlines inside code blocks (markdown-it adds them, TipTap shows them as blank lines)
|
// Post-process: strip trailing newlines inside code blocks (markdown-it adds them, TipTap shows them as blank lines)
|
||||||
html = html.replace(/<code([^>]*)>\n?/g, '<code$1>');
|
html = html.replace(/<code([^>]*)>\n?/g, '<code$1>');
|
||||||
html = html.replace(/\n<\/code>/g, '</code>');
|
html = html.replace(/\n<\/code>/g, '</code>');
|
||||||
@@ -1835,7 +1951,7 @@
|
|||||||
CustomTableCell,
|
CustomTableCell,
|
||||||
CustomTableHeader,
|
CustomTableHeader,
|
||||||
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:') }),
|
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:') }),
|
||||||
CustomImage.configure({ HTMLAttributes: { class: 'editor-image' } }),
|
CustomImage.configure({ inline: true, HTMLAttributes: { class: 'editor-image' } }),
|
||||||
Highlight.configure({ multicolor: true }),
|
Highlight.configure({ multicolor: true }),
|
||||||
Typography,
|
Typography,
|
||||||
Underline,
|
Underline,
|
||||||
@@ -1853,6 +1969,7 @@
|
|||||||
DetailsContent,
|
DetailsContent,
|
||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
SlashCommands,
|
SlashCommands,
|
||||||
|
MoveLineShortcuts,
|
||||||
NoteSearchExtension,
|
NoteSearchExtension,
|
||||||
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
|
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
|
||||||
],
|
],
|
||||||
@@ -1896,6 +2013,7 @@
|
|||||||
autoSave();
|
autoSave();
|
||||||
// Fix any blob: URLs from pasted web images
|
// Fix any blob: URLs from pasted web images
|
||||||
fixBlobImages();
|
fixBlobImages();
|
||||||
|
if (showOutline) updateOutline();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
editorReady = true;
|
editorReady = true;
|
||||||
@@ -2801,6 +2919,16 @@
|
|||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
class:active={showOutline}
|
||||||
|
onclick={() => { showOutline = !showOutline; if (showOutline) updateOutline(); }}
|
||||||
|
title="Outline"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:active={showHistory}
|
class:active={showHistory}
|
||||||
@@ -2887,20 +3015,72 @@
|
|||||||
<div class="editor-body-row">
|
<div class="editor-body-row">
|
||||||
<div class="editor-body">
|
<div class="editor-body">
|
||||||
{#if $sourceMode}
|
{#if $sourceMode}
|
||||||
|
{#if $appConfig?.show_line_numbers}
|
||||||
|
<div class="line-numbers" aria-hidden="true">
|
||||||
|
{#each sourceContent.split('\n') as _, i}
|
||||||
|
<span>{i + 1}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<textarea
|
<textarea
|
||||||
class="source-editor"
|
class="source-editor"
|
||||||
|
class:with-line-numbers={$appConfig?.show_line_numbers}
|
||||||
bind:this={sourceElement}
|
bind:this={sourceElement}
|
||||||
bind:value={sourceContent}
|
bind:value={sourceContent}
|
||||||
readonly={$readOnly}
|
readonly={$readOnly}
|
||||||
|
onclick={() => { showOutline = false; }}
|
||||||
oninput={() => {
|
oninput={() => {
|
||||||
$editorDirty = true;
|
$editorDirty = true;
|
||||||
autoSave();
|
autoSave();
|
||||||
}}
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ta = sourceElement;
|
||||||
|
const val = ta.value;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const lines = val.split('\n');
|
||||||
|
// Find current line index
|
||||||
|
let pos = 0;
|
||||||
|
let curLine = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (pos + lines[i].length >= start) { curLine = i; break; }
|
||||||
|
pos += lines[i].length + 1;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' && curLine > 0) {
|
||||||
|
const tmp = lines[curLine];
|
||||||
|
lines[curLine] = lines[curLine - 1];
|
||||||
|
lines[curLine - 1] = tmp;
|
||||||
|
sourceContent = lines.join('\n');
|
||||||
|
tick().then(() => {
|
||||||
|
const newPos = lines.slice(0, curLine - 1).join('\n').length + 1 + (start - pos);
|
||||||
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
});
|
||||||
|
} else if (e.key === 'ArrowDown' && curLine < lines.length - 1) {
|
||||||
|
const tmp = lines[curLine];
|
||||||
|
lines[curLine] = lines[curLine + 1];
|
||||||
|
lines[curLine + 1] = tmp;
|
||||||
|
sourceContent = lines.join('\n');
|
||||||
|
tick().then(() => {
|
||||||
|
const newPos = lines.slice(0, curLine + 1).join('\n').length + 1 + (start - pos);
|
||||||
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$editorDirty = true;
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onscroll={() => {
|
||||||
|
if ($appConfig?.show_line_numbers) {
|
||||||
|
const gutter = sourceElement?.previousElementSibling as HTMLElement;
|
||||||
|
if (gutter) gutter.scrollTop = sourceElement.scrollTop;
|
||||||
|
}
|
||||||
|
}}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
></textarea>
|
></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="tiptap-wrapper" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); }} oncontextmenu={handleEditorContextMenu}></div>
|
<div class="tiptap-wrapper" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2950,6 +3130,33 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showOutline}
|
||||||
|
<div class="outline-panel">
|
||||||
|
<div class="outline-header">
|
||||||
|
<h3>Outline</h3>
|
||||||
|
<button class="outline-close" onclick={() => { showOutline = false; }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if outlineHeadings.length === 0}
|
||||||
|
<div class="outline-empty">No headings in this note.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="outline-list">
|
||||||
|
{#each outlineHeadings as h}
|
||||||
|
<button
|
||||||
|
class="outline-item outline-level-{h.level}"
|
||||||
|
onclick={() => scrollToHeading(h.pos)}
|
||||||
|
>
|
||||||
|
{h.text}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4139,6 +4346,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-body:has(.source-editor) {
|
.editor-body:has(.source-editor) {
|
||||||
@@ -4269,6 +4477,90 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-panel {
|
||||||
|
width: 220px;
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-header h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-close:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-empty {
|
||||||
|
padding: 16px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-level-1 { padding-left: 14px; font-weight: 600; }
|
||||||
|
.outline-level-2 { padding-left: 26px; font-weight: 500; }
|
||||||
|
.outline-level-3 { padding-left: 38px; }
|
||||||
|
.outline-level-4 { padding-left: 50px; }
|
||||||
|
.outline-level-5 { padding-left: 62px; }
|
||||||
|
.outline-level-6 { padding-left: 74px; }
|
||||||
|
|
||||||
.source-editor {
|
.source-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -4284,6 +4576,34 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.source-editor.with-line-numbers {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 0;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: var(--editor-font-size, 14px);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers span {
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.tiptap-wrapper {
|
.tiptap-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
@@ -4392,6 +4712,70 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Syntax highlighting — light mode */
|
||||||
|
:global(.tiptap pre code .hljs-keyword),
|
||||||
|
:global(.tiptap pre code .hljs-selector-tag),
|
||||||
|
:global(.tiptap pre code .hljs-built_in) { color: #d73a49; }
|
||||||
|
:global(.tiptap pre code .hljs-string),
|
||||||
|
:global(.tiptap pre code .hljs-addition) { color: #032f62; }
|
||||||
|
:global(.tiptap pre code .hljs-number),
|
||||||
|
:global(.tiptap pre code .hljs-literal) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-comment),
|
||||||
|
:global(.tiptap pre code .hljs-quote) { color: #6a737d; font-style: italic; }
|
||||||
|
:global(.tiptap pre code .hljs-function),
|
||||||
|
:global(.tiptap pre code .hljs-title) { color: #6f42c1; }
|
||||||
|
:global(.tiptap pre code .hljs-type),
|
||||||
|
:global(.tiptap pre code .hljs-title.class_) { color: #e36209; }
|
||||||
|
:global(.tiptap pre code .hljs-variable),
|
||||||
|
:global(.tiptap pre code .hljs-template-variable) { color: #e36209; }
|
||||||
|
:global(.tiptap pre code .hljs-attr),
|
||||||
|
:global(.tiptap pre code .hljs-attribute) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-tag) { color: #22863a; }
|
||||||
|
:global(.tiptap pre code .hljs-name) { color: #22863a; }
|
||||||
|
:global(.tiptap pre code .hljs-meta) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-deletion) { color: #b31d28; background: #ffeef0; }
|
||||||
|
:global(.tiptap pre code .hljs-symbol),
|
||||||
|
:global(.tiptap pre code .hljs-bullet) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-regexp) { color: #032f62; }
|
||||||
|
:global(.tiptap pre code .hljs-params) { color: #24292e; }
|
||||||
|
:global(.tiptap pre code .hljs-punctuation) { color: #24292e; }
|
||||||
|
:global(.tiptap pre code .hljs-property) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-selector-class) { color: #6f42c1; }
|
||||||
|
:global(.tiptap pre code .hljs-selector-id) { color: #005cc5; }
|
||||||
|
:global(.tiptap pre code .hljs-operator) { color: #d73a49; }
|
||||||
|
|
||||||
|
/* Syntax highlighting — dark mode */
|
||||||
|
:global(.dark .tiptap pre code .hljs-keyword),
|
||||||
|
:global(.dark .tiptap pre code .hljs-selector-tag),
|
||||||
|
:global(.dark .tiptap pre code .hljs-built_in) { color: #ff7b72; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-string),
|
||||||
|
:global(.dark .tiptap pre code .hljs-addition) { color: #a5d6ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-number),
|
||||||
|
:global(.dark .tiptap pre code .hljs-literal) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-comment),
|
||||||
|
:global(.dark .tiptap pre code .hljs-quote) { color: #8b949e; font-style: italic; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-function),
|
||||||
|
:global(.dark .tiptap pre code .hljs-title) { color: #d2a8ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-type),
|
||||||
|
:global(.dark .tiptap pre code .hljs-title.class_) { color: #ffa657; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-variable),
|
||||||
|
:global(.dark .tiptap pre code .hljs-template-variable) { color: #ffa657; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-attr),
|
||||||
|
:global(.dark .tiptap pre code .hljs-attribute) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-tag) { color: #7ee787; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-name) { color: #7ee787; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-meta) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-deletion) { color: #ffdcd7; background: #67060c; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-symbol),
|
||||||
|
:global(.dark .tiptap pre code .hljs-bullet) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-regexp) { color: #a5d6ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-params) { color: #c9d1d9; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-punctuation) { color: #c9d1d9; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-property) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-selector-class) { color: #d2a8ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-selector-id) { color: #79c0ff; }
|
||||||
|
:global(.dark .tiptap pre code .hljs-operator) { color: #ff7b72; }
|
||||||
|
|
||||||
.code-lang-overlay {
|
.code-lang-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -308,6 +308,7 @@
|
|||||||
let pdfHeight = $state($appConfig?.pdf_height ?? 600);
|
let pdfHeight = $state($appConfig?.pdf_height ?? 600);
|
||||||
let titleMode = $state($appConfig?.title_mode ?? 'input');
|
let titleMode = $state($appConfig?.title_mode ?? 'input');
|
||||||
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
|
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
|
||||||
|
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
|
||||||
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
|
||||||
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
|
||||||
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
let closeToTray = $state($appConfig?.close_to_tray ?? false);
|
||||||
@@ -360,12 +361,13 @@
|
|||||||
$appConfig.pdf_height = pdfHeight;
|
$appConfig.pdf_height = pdfHeight;
|
||||||
$appConfig.title_mode = titleMode;
|
$appConfig.title_mode = titleMode;
|
||||||
$appConfig.hide_title_in_body = hideTitleInBody;
|
$appConfig.hide_title_in_body = hideTitleInBody;
|
||||||
|
$appConfig.show_line_numbers = showLineNumbers;
|
||||||
$appConfig.default_view_mode = defaultViewMode;
|
$appConfig.default_view_mode = defaultViewMode;
|
||||||
$appConfig.show_tray_icon = showTrayIcon;
|
$appConfig.show_tray_icon = showTrayIcon;
|
||||||
$appConfig.close_to_tray = closeToTray;
|
$appConfig.close_to_tray = closeToTray;
|
||||||
$appConfig.enable_wiki_links = enableWikiLinks;
|
$appConfig.enable_wiki_links = enableWikiLinks;
|
||||||
}
|
}
|
||||||
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
|
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
|
||||||
.catch((e) => console.error('Failed to save general settings:', e));
|
.catch((e) => console.error('Failed to save general settings:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,6 +627,15 @@
|
|||||||
<span class="toggle-knob"></span>
|
<span class="toggle-knob"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="setting-toggle" style="margin-top: 12px;">
|
||||||
|
<span class="setting-label">
|
||||||
|
<span class="setting-name">Show line numbers</span>
|
||||||
|
<span class="setting-desc">Display line numbers in the markdown source editor</span>
|
||||||
|
</span>
|
||||||
|
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
|
||||||
|
<span class="toggle-knob"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
<label class="setting-toggle" style="margin-top: 12px;">
|
<label class="setting-toggle" style="margin-top: 12px;">
|
||||||
<span class="setting-label">
|
<span class="setting-label">
|
||||||
<span class="setting-name">Open notes in View Mode</span>
|
<span class="setting-name">Open notes in View Mode</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user