mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1faf0b0f5 | ||
|
|
564c28904f | ||
|
|
06d53cea66 | ||
|
|
d9fe358e6b | ||
|
|
253449acc2 |
@@ -2,24 +2,51 @@
|
|||||||
|
|
||||||
A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
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.
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
|
No cloud, no lock-in.
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
| Platform | Download | Notes |
|
### Linux
|
||||||
|----------|----------|-------|
|
|
||||||
| Linux (Arch/rolling) | [HelixNotes_1.1.0_amd64.AppImage](https://download.helixnotes.com/releases/v1.1.0/HelixNotes_1.1.0_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
|
||||||
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.1.0_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_amd64.deb) | Ubuntu 22.04+ |
|
|
||||||
| Windows | [HelixNotes_1.1.0_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64-setup.exe) | Windows 10/11 |
|
|
||||||
| macOS | [HelixNotes_1.1.0_x64.dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64.dmg) | macOS 12+ (Intel, runs on Apple Silicon via Rosetta) |
|
|
||||||
|
|
||||||
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
#### Arch / Manjaro (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S helixnotes-appimage-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Debian / Ubuntu / Mint (APT)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### AppImage (any distro)
|
||||||
|
|
||||||
|
[Download AppImage](https://download.helixnotes.com/releases/v1.1.2/HelixNotes_1.1.2_amd64.AppImage)
|
||||||
|
|
||||||
|
#### .deb (manual)
|
||||||
|
|
||||||
|
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_amd64.deb) — Ubuntu 22.04+
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_x64-setup.exe) — Windows 10/11
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.2/HelixNotes_1.1.2_x64.dmg) — macOS 12+ (Intel, runs on Apple Silicon via Rosetta)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode
|
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting
|
||||||
- **Wiki-links** — link notes with `[[Note Title]]` syntax
|
- **Wiki-links** — link notes with `[[Note Title]]` syntax
|
||||||
- **Graph view** — visualize connections between your notes
|
- **Graph view** — visualize connections between your notes
|
||||||
|
- **Outline panel** — heading navigation for long notes
|
||||||
- **Full-text search** powered by Tantivy
|
- **Full-text search** powered by Tantivy
|
||||||
- **Math support** — KaTeX rendering for inline and block equations
|
- **Math support** — KaTeX rendering for inline and block equations
|
||||||
- **Daily notes** — one-click button to create or open today's note
|
- **Daily notes** — one-click button to create or open today's note
|
||||||
@@ -35,6 +62,8 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or
|
|||||||
- **View mode** — read-only toggle for distraction-free reading
|
- **View mode** — read-only toggle for distraction-free reading
|
||||||
- **Local** — everything stays on your machine
|
- **Local** — everything stays on your machine
|
||||||
|
|
||||||
|
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
||||||
@@ -65,4 +94,4 @@ pnpm tauri build
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||||
|
|||||||
+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.3",
|
||||||
"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.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.1.1"
|
version = "1.1.3"
|
||||||
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.3",
|
||||||
"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,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.forceSave();
|
editor?.forceSave();
|
||||||
}
|
}
|
||||||
|
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
editor?.addLinkFromToolbar();
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@
|
|||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
let editorReady = $state(false);
|
let editorReady = $state(false);
|
||||||
let sourceContent = $state('');
|
let sourceContent = $state('');
|
||||||
|
let sourceHistory: Array<{ content: string; cursor: number }> = [];
|
||||||
|
let sourceHistoryIndex = -1;
|
||||||
|
let sourceHistoryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let loadedPath = '';
|
let loadedPath = '';
|
||||||
let pendingContent = $state<string | null>(null);
|
let pendingContent = $state<string | null>(null);
|
||||||
let ignoreNextUpdate = false;
|
let ignoreNextUpdate = false;
|
||||||
@@ -99,6 +102,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);
|
||||||
@@ -158,6 +184,8 @@
|
|||||||
let linkModal = $state(false);
|
let linkModal = $state(false);
|
||||||
let linkModalUrl = $state('');
|
let linkModalUrl = $state('');
|
||||||
let linkModalInput = $state<HTMLInputElement>(null!);
|
let linkModalInput = $state<HTMLInputElement>(null!);
|
||||||
|
let linkSelectionFrom = 0;
|
||||||
|
let linkSelectionTo = 0;
|
||||||
let textContextMenu = $state<{ x: number; y: number } | null>(null);
|
let textContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
|
let tableContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||||
let tablePickerOpen = $state(false);
|
let tablePickerOpen = $state(false);
|
||||||
@@ -377,6 +405,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() {
|
||||||
@@ -1112,6 +1200,7 @@
|
|||||||
if (editor) editor.setEditable(!shouldBeReadOnly);
|
if (editor) editor.setEditable(!shouldBeReadOnly);
|
||||||
if ($sourceMode) {
|
if ($sourceMode) {
|
||||||
sourceContent = stripTitleH1(content);
|
sourceContent = stripTitleH1(content);
|
||||||
|
resetSourceHistory(sourceContent);
|
||||||
isLoadingNote = false;
|
isLoadingNote = false;
|
||||||
} else if (editorElement && editor) {
|
} else if (editorElement && editor) {
|
||||||
// Editor already exists, just swap content
|
// Editor already exists, just swap content
|
||||||
@@ -1177,9 +1266,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 +1291,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 +1393,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 +1469,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 || ''}$`);
|
||||||
@@ -1440,6 +1554,69 @@
|
|||||||
sourceElement.scrollTop = Math.max(0, targetScroll);
|
sourceElement.scrollTop = Math.max(0, targetScroll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSourceHistory(content: string) {
|
||||||
|
sourceHistory = [{ content, cursor: content.length }];
|
||||||
|
sourceHistoryIndex = 0;
|
||||||
|
if (sourceHistoryTimer) {
|
||||||
|
clearTimeout(sourceHistoryTimer);
|
||||||
|
sourceHistoryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSourceHistoryImmediate() {
|
||||||
|
if (!sourceElement) return;
|
||||||
|
if (sourceHistoryTimer) {
|
||||||
|
clearTimeout(sourceHistoryTimer);
|
||||||
|
sourceHistoryTimer = null;
|
||||||
|
}
|
||||||
|
const entry = { content: sourceContent, cursor: sourceElement.selectionStart };
|
||||||
|
// Don't push duplicate
|
||||||
|
if (sourceHistoryIndex >= 0 && sourceHistory[sourceHistoryIndex]?.content === entry.content) return;
|
||||||
|
// Truncate any redo history
|
||||||
|
sourceHistory = sourceHistory.slice(0, sourceHistoryIndex + 1);
|
||||||
|
sourceHistory.push(entry);
|
||||||
|
sourceHistoryIndex++;
|
||||||
|
// Limit stack size
|
||||||
|
if (sourceHistory.length > 200) {
|
||||||
|
sourceHistory.shift();
|
||||||
|
sourceHistoryIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSourceHistoryDebounced() {
|
||||||
|
if (sourceHistoryTimer) clearTimeout(sourceHistoryTimer);
|
||||||
|
sourceHistoryTimer = setTimeout(() => {
|
||||||
|
sourceHistoryTimer = null;
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceUndo() {
|
||||||
|
// Flush any pending debounced snapshot first
|
||||||
|
if (sourceHistoryTimer) {
|
||||||
|
clearTimeout(sourceHistoryTimer);
|
||||||
|
sourceHistoryTimer = null;
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
|
}
|
||||||
|
if (sourceHistoryIndex <= 0) return;
|
||||||
|
sourceHistoryIndex--;
|
||||||
|
const entry = sourceHistory[sourceHistoryIndex];
|
||||||
|
sourceContent = entry.content;
|
||||||
|
tick().then(() => {
|
||||||
|
sourceElement?.setSelectionRange(entry.cursor, entry.cursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceRedo() {
|
||||||
|
if (sourceHistoryIndex >= sourceHistory.length - 1) return;
|
||||||
|
sourceHistoryIndex++;
|
||||||
|
const entry = sourceHistory[sourceHistoryIndex];
|
||||||
|
sourceContent = entry.content;
|
||||||
|
tick().then(() => {
|
||||||
|
sourceElement?.setSelectionRange(entry.cursor, entry.cursor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applySearchDecorations() {
|
function applySearchDecorations() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const decorations = noteSearchResults.map((m, i) =>
|
const decorations = noteSearchResults.map((m, i) =>
|
||||||
@@ -1716,9 +1893,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 +2020,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 +2038,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] : []),
|
||||||
],
|
],
|
||||||
@@ -1872,6 +2058,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Prevent native text drag — it causes copy-instead-of-move in Tauri's webview.
|
||||||
|
// File drops from OS are handled by Tauri's onDragDropEvent listener instead.
|
||||||
|
dragstart: (_view, event) => {
|
||||||
|
const dt = event.dataTransfer;
|
||||||
|
if (!dt || dt.files.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
handleDrop: (_view, event) => handleFileDrop(event),
|
handleDrop: (_view, event) => handleFileDrop(event),
|
||||||
handlePaste: (_view, event) => handleFilePaste(event),
|
handlePaste: (_view, event) => handleFilePaste(event),
|
||||||
@@ -1896,6 +2090,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;
|
||||||
@@ -1905,8 +2100,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLinkFromToolbar() {
|
export function addLinkFromToolbar() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
|
const { from, to } = editor.state.selection;
|
||||||
|
linkSelectionFrom = from;
|
||||||
|
linkSelectionTo = to;
|
||||||
const previousUrl = editor.getAttributes('link').href || '';
|
const previousUrl = editor.getAttributes('link').href || '';
|
||||||
linkModalUrl = previousUrl;
|
linkModalUrl = previousUrl;
|
||||||
linkModal = true;
|
linkModal = true;
|
||||||
@@ -1915,11 +2113,14 @@
|
|||||||
|
|
||||||
function linkModalConfirm() {
|
function linkModalConfirm() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const url = linkModalUrl.trim();
|
let url = linkModalUrl.trim();
|
||||||
if (url === '') {
|
if (url === '') {
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run();
|
||||||
} else {
|
} else {
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#')) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href: url }).run();
|
||||||
}
|
}
|
||||||
linkModal = false;
|
linkModal = false;
|
||||||
linkModalUrl = '';
|
linkModalUrl = '';
|
||||||
@@ -2625,6 +2826,7 @@
|
|||||||
if (isSource && !lastSourceMode) {
|
if (isSource && !lastSourceMode) {
|
||||||
// Switching TO source: extract markdown from editor
|
// Switching TO source: extract markdown from editor
|
||||||
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
sourceContent = editor ? editorToMarkdown() : ($activeNote?.content ?? '');
|
||||||
|
resetSourceHistory(sourceContent);
|
||||||
lastSourceMode = true;
|
lastSourceMode = true;
|
||||||
} else if (!isSource && lastSourceMode) {
|
} else if (!isSource && lastSourceMode) {
|
||||||
// Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone),
|
// Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone),
|
||||||
@@ -2801,6 +3003,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 +3099,111 @@
|
|||||||
<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-clip" aria-hidden="true">
|
||||||
|
<div class="line-numbers">
|
||||||
|
{#each sourceContent.split('\n') as _, i}
|
||||||
|
<span>{i + 1}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</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();
|
||||||
|
pushSourceHistoryDebounced();
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
// Shift+Enter: insert two trailing spaces + newline for markdown hard break
|
||||||
|
if (e.key === 'Enter' && e.shiftKey && !mod) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ta = sourceElement;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const val = ta.value;
|
||||||
|
sourceContent = val.slice(0, start) + ' \n' + val.slice(end);
|
||||||
|
tick().then(() => {
|
||||||
|
const newPos = start + 3;
|
||||||
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
});
|
||||||
|
$editorDirty = true;
|
||||||
|
autoSave();
|
||||||
|
pushSourceHistoryDebounced();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Undo
|
||||||
|
if (mod && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sourceUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Redo
|
||||||
|
if ((mod && (e.key === 'z' || e.key === 'Z') && e.shiftKey) || (mod && (e.key === 'y' || e.key === 'Y'))) {
|
||||||
|
e.preventDefault();
|
||||||
|
sourceRedo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||||
|
e.preventDefault();
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
|
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);
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
|
});
|
||||||
|
} 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);
|
||||||
|
pushSourceHistoryImmediate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$editorDirty = true;
|
||||||
|
autoSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onscroll={() => {
|
||||||
|
if ($appConfig?.show_line_numbers) {
|
||||||
|
const clip = sourceElement?.previousElementSibling as HTMLElement;
|
||||||
|
const gutter = clip?.firstElementChild as HTMLElement;
|
||||||
|
if (gutter) {
|
||||||
|
gutter.style.transform = `translateY(-${sourceElement.scrollTop}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
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 +3253,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>
|
||||||
|
|
||||||
@@ -3778,8 +4108,8 @@
|
|||||||
bind:this={linkModalInput}
|
bind:this={linkModalInput}
|
||||||
bind:value={linkModalUrl}
|
bind:value={linkModalUrl}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter') linkModalConfirm();
|
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); }
|
||||||
if (e.key === 'Escape') linkModalCancel();
|
if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); }
|
||||||
}}
|
}}
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
/>
|
/>
|
||||||
@@ -4139,6 +4469,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 +4600,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%;
|
||||||
@@ -4281,7 +4696,41 @@
|
|||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-editor.with-line-numbers {
|
||||||
|
padding-left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers-clip {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers {
|
||||||
|
padding-top: 8px;
|
||||||
|
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;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-numbers span {
|
||||||
|
display: block;
|
||||||
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper {
|
.tiptap-wrapper {
|
||||||
@@ -4392,6 +4841,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