Merge pull request #205 from gamosoft/features/auto-continue

Features/auto continue
This commit is contained in:
Guillermo Villar 2026-04-13 16:23:59 +02:00 committed by GitHub
commit ae7141a18f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 256 additions and 54 deletions

View File

@ -526,7 +526,11 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
"""Get metadata for a note""" """Get metadata for a note"""
full_path = Path(notes_dir) / note_path full_path = Path(notes_dir) / note_path
if not full_path.exists(): if not full_path.exists() or not full_path.is_file():
return {}
# Security check: ensure the path is within notes_dir (same as get_note_content)
if not validate_path_security(notes_dir, full_path):
return {} return {}
stat = full_path.stat() stat = full_path.stat()

View File

@ -23,6 +23,7 @@
- **Documents** - PDF (default max 20MB, configurable) - **Documents** - PDF (default max 20MB, configurable)
- **In-app viewing** - View all media types directly in the sidebar - **In-app viewing** - View all media types directly in the sidebar
- **Inline preview** - Audio/video players and PDF viewer embedded in notes - **Inline preview** - Audio/video players and PDF viewer embedded in notes
- **Relative media paths** - For `![alt](path)`, paths resolve from the notes folder
### Organization ### Organization
- **Folder hierarchy** - Organize notes in nested folders - **Folder hierarchy** - Organize notes in nested folders
@ -32,6 +33,7 @@
- **Visual tree view** - Expandable/collapsible navigation - **Visual tree view** - Expandable/collapsible navigation
- **Hide system folders** - Toggle to hide `_attachments`, `_templates` and other underscore-prefixed folders from sidebar - **Hide system folders** - Toggle to hide `_attachments`, `_templates` and other underscore-prefixed folders from sidebar
- **Tab inserts tab** - Toggle to make Tab key insert a tab character in the editor instead of changing focus - **Tab inserts tab** - Toggle to make Tab key insert a tab character in the editor instead of changing focus
- **List & quote continuation (Enter)** - At the end of a line, Enter continues blockquotes (`>`), bullets (`-` / `*` / `+`), and task lists (`- [ ]` / `- [x]`); pressing Enter again on an empty continued line exits the list or quote. Disabled inside fenced code blocks and when using Shift+Enter (plain newline)
### Export & Print ### Export & Print
- **HTML Export** - Download notes as standalone HTML files with all styling, images, diagrams, and math embedded - **HTML Export** - Download notes as standalone HTML files with all styling, images, diagrams, and math embedded

View File

@ -193,6 +193,7 @@ function noteApp() {
// Preview rendering debounce // Preview rendering debounce
_previewDebounceTimeout: null, _previewDebounceTimeout: null,
_lastRenderedContent: '', _lastRenderedContent: '',
_lastRenderedNote: '',
_cachedRenderedHTML: '', _cachedRenderedHTML: '',
_mathDebounceTimeout: null, _mathDebounceTimeout: null,
_mermaidDebounceTimeout: null, _mermaidDebounceTimeout: null,
@ -857,6 +858,31 @@ function noteApp() {
this.autoSave(); this.autoSave();
}, },
/**
* Enter: continue blockquote / bullet / task list; second Enter on empty item exits (see editor-markdown-continue.js).
*/
handleEditorEnterKey(event) {
if (typeof EditorMarkdownContinue === 'undefined') return;
const textarea = event.target;
if (!textarea || textarea.id !== 'note-editor') return;
const result = EditorMarkdownContinue.tryEnter(
this.noteContent,
textarea.selectionStart,
textarea.selectionEnd,
event
);
if (!result.handled) return;
event.preventDefault();
this.noteContent = result.text;
this.$nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = result.cursor;
});
this.autoSave();
this.updateSyntaxHighlight();
},
// Sort mode configuration // Sort mode configuration
sortModes: ['a-z', 'z-a', 'newest', 'oldest', 'largest', 'smallest'], sortModes: ['a-z', 'z-a', 'newest', 'oldest', 'largest', 'smallest'],
sortModeIcons: { sortModeIcons: {
@ -1262,6 +1288,49 @@ function noteApp() {
return this._mediaLookup.get(nameLower) || null; return this._mediaLookup.get(nameLower) || null;
}, },
// Resolve a Markdown image path to a vault-relative path (forward slashes).
// Relative paths use the note file's directory as base (same rules as CommonMark / browsers).
// A leading "/" (not "//") means vault-root-relative.
resolveMarkdownMediaPathForNote(noteVaultPath, rawSrc) {
const pathPart = rawSrc.split('#')[0].split('?')[0];
if (!pathPart) return '';
if (pathPart.startsWith('/') && !pathPart.startsWith('//')) {
return pathPart.replace(/^\/+/, '');
}
if (!noteVaultPath) {
return pathPart;
}
const dir = noteVaultPath.includes('/')
? noteVaultPath.slice(0, noteVaultPath.lastIndexOf('/'))
: '';
const base = `https://vn.invalid/${dir ? `${dir}/` : ''}`;
try {
const u = new URL(pathPart, base);
let p = u.pathname;
if (p.startsWith('/')) p = p.slice(1);
return p.split('/').filter(seg => seg !== '').map(seg => {
try {
return decodeURIComponent(seg);
} catch (e) {
return seg;
}
}).join('/');
} catch (e) {
return pathPart;
}
},
encodeVaultRelativePathForMediaApi(vaultRelativePath) {
if (!vaultRelativePath) return '';
return vaultRelativePath.split('/').map(segment => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch (e) {
return encodeURIComponent(segment);
}
}).join('/');
},
// Load all tags // Load all tags
async loadTags() { async loadTags() {
try { try {
@ -2833,6 +2902,7 @@ function noteApp() {
this.currentNote = notePath; this.currentNote = notePath;
this._lastRenderedContent = ''; // Clear render cache for new note this._lastRenderedContent = ''; // Clear render cache for new note
this._lastRenderedNote = '';
this._cachedRenderedHTML = ''; this._cachedRenderedHTML = '';
this._initializedVideoSources = new Set(); // Clear video cache for new note this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content; this.noteContent = data.content;
@ -3937,6 +4007,7 @@ function noteApp() {
this.noteContent = ''; this.noteContent = '';
this.currentNoteName = ''; this.currentNoteName = '';
this._lastRenderedContent = ''; // Clear render cache this._lastRenderedContent = ''; // Clear render cache
this._lastRenderedNote = '';
this._cachedRenderedHTML = ''; this._cachedRenderedHTML = '';
document.title = this.appName; document.title = this.appName;
// Redirect to root // Redirect to root
@ -4108,7 +4179,9 @@ function noteApp() {
if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>'; if (!this.noteContent) return '<p style="color: var(--text-tertiary);">Nothing to preview yet...</p>';
// Performance: Return cached HTML if content hasn't changed // Performance: Return cached HTML if content hasn't changed
if (this.noteContent === this._lastRenderedContent && this._cachedRenderedHTML) { if (this.noteContent === this._lastRenderedContent &&
this.currentNote === this._lastRenderedNote &&
this._cachedRenderedHTML) {
return this._cachedRenderedHTML; return this._cachedRenderedHTML;
} }
@ -4302,14 +4375,10 @@ function noteApp() {
// Transform relative paths to /api/media/ for serving // Transform relative paths to /api/media/ for serving
if (isLocal && !src.startsWith('/api/media/')) { if (isLocal && !src.startsWith('/api/media/')) {
// URL-encode path segments to handle spaces and special characters const vaultRelative = self.currentNote
const encodedPath = src.split('/').map(segment => { ? self.resolveMarkdownMediaPathForNote(self.currentNote, src)
try { : src.split('#')[0].split('?')[0];
return encodeURIComponent(decodeURIComponent(segment)); const encodedPath = self.encodeVaultRelativePathForMediaApi(vaultRelative);
} catch (e) {
return encodeURIComponent(segment);
}
}).join('/');
src = `/api/media/${encodedPath}`; src = `/api/media/${encodedPath}`;
img.setAttribute('src', src); img.setAttribute('src', src);
} }
@ -4409,6 +4478,7 @@ function noteApp() {
// Cache the result for performance // Cache the result for performance
this._lastRenderedContent = this.noteContent; this._lastRenderedContent = this.noteContent;
this._lastRenderedNote = this.currentNote;
this._cachedRenderedHTML = html; this._cachedRenderedHTML = html;
return html; return html;

View File

@ -0,0 +1,124 @@
/**
* Markdown editor helpers: Enter continues blockquotes, bullets, and task lists.
* Plain functions only; no DOM. Used by app.js handleEditorEnterKey.
*/
(function (global) {
'use strict';
/**
* True if cursor position lies inside a ``` fenced code block (toggle per line).
*/
function cursorInMarkdownCodeFence(text, pos) {
const before = text.slice(0, pos);
let lineStart = 0;
let inFence = false;
for (;;) {
const nl = before.indexOf('\n', lineStart);
const end = nl === -1 ? before.length : nl;
const line = before.slice(lineStart, end);
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
inFence = !inFence;
}
if (nl === -1) break;
lineStart = nl + 1;
}
return inFence;
}
function getLineBounds(text, pos) {
const lineStart = text.lastIndexOf('\n', Math.max(0, pos - 1)) + 1;
let lineEnd = text.indexOf('\n', pos);
if (lineEnd === -1) lineEnd = text.length;
return {
lineStart,
lineEnd,
line: text.slice(lineStart, lineEnd),
};
}
/**
* @returns {{ continuePrefix: string, isEmpty: boolean } | null}
*/
function parseContinuationContext(line) {
const quoteMatch = line.match(/^(\s*)((?:>\s*)+)/);
let quoteFull = '';
let rest = line;
if (quoteMatch) {
quoteFull = quoteMatch[1] + quoteMatch[2];
rest = line.slice(quoteMatch[0].length);
}
const taskMatch = rest.match(/^(\s*)([-*+])\s+\[([xX ])\]\s*(.*)$/);
if (taskMatch) {
const inner = taskMatch[1];
const ch = taskMatch[2];
const mark = taskMatch[3];
const cont = taskMatch[4];
const marker = ch + ' [' + mark + '] ';
const isEmpty = cont.trim() === '';
return { continuePrefix: quoteFull + inner + marker, isEmpty };
}
const bulletMatch = rest.match(/^(\s*)([-*+])\s+(.*)$/);
if (bulletMatch) {
const inner = bulletMatch[1];
const ch = bulletMatch[2];
const cont = bulletMatch[3];
const marker = ch + ' ';
const isEmpty = cont.trim() === '';
return { continuePrefix: quoteFull + inner + marker, isEmpty };
}
if (quoteFull) {
const isEmpty = rest.trim() === '';
return { continuePrefix: quoteFull, isEmpty };
}
return null;
}
/**
* @param {string} text
* @param {number} selStart
* @param {number} selEnd
* @param {KeyboardEvent} event
* @returns {{ handled: false } | { handled: true, text: string, cursor: number }}
*/
function tryEnter(text, selStart, selEnd, event) {
if (event.key !== 'Enter') return { handled: false };
if (event.defaultPrevented) return { handled: false };
if (event.isComposing) return { handled: false };
if (event.shiftKey) return { handled: false };
if (event.ctrlKey || event.metaKey || event.altKey) return { handled: false };
if (selStart !== selEnd) return { handled: false };
if (cursorInMarkdownCodeFence(text, selStart)) return { handled: false };
const { lineStart, lineEnd, line } = getLineBounds(text, selStart);
if (selStart !== lineEnd) return { handled: false };
const lineForParse = line.replace(/\r/g, '');
const ctx = parseContinuationContext(lineForParse);
if (!ctx) return { handled: false };
if (ctx.isEmpty) {
let delEnd = lineEnd;
if (delEnd < text.length && text.charAt(delEnd) === '\n') {
delEnd += 1;
}
const newText = text.slice(0, lineStart) + text.slice(delEnd);
return { handled: true, text: newText, cursor: lineStart };
}
const insert = '\n' + ctx.continuePrefix;
const newText = text.slice(0, lineEnd) + insert + text.slice(lineEnd);
return { handled: true, text: newText, cursor: lineEnd + insert.length };
}
global.EditorMarkdownContinue = {
tryEnter,
parseContinuationContext,
getLineBounds,
cursorInMarkdownCodeFence,
};
})(typeof window !== 'undefined' ? window : globalThis);

View File

@ -2682,6 +2682,7 @@
x-model="noteContent" x-model="noteContent"
@input="autoSave(); updateSyntaxHighlight()" @input="autoSave(); updateSyntaxHighlight()"
@scroll="syncOverlayScroll()" @scroll="syncOverlayScroll()"
@keydown.enter="handleEditorEnterKey($event)"
@keydown.tab="handleTabKey($event)" @keydown.tab="handleTabKey($event)"
@drop="onEditorDrop($event)" @drop="onEditorDrop($event)"
@dragover.prevent="onEditorDragOver($event)" @dragover.prevent="onEditorDragOver($event)"
@ -3433,6 +3434,7 @@
</button> </button>
</nav> </nav>
<script src="/static/editor-markdown-continue.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<!-- PWA Service Worker Registration --> <!-- PWA Service Worker Registration -->

View File

@ -51,10 +51,10 @@
"collapse_all": "Alle Ordner zuklappen", "collapse_all": "Alle Ordner zuklappen",
"sort_a-z": "Sortieren A-Z", "sort_a-z": "Sortieren A-Z",
"sort_z-a": "Sortieren Z-A", "sort_z-a": "Sortieren Z-A",
"sort_newest": "Nach neuesten sortieren", "sort_newest": "Nach neuesten sortieren (nur Dateien)",
"sort_oldest": "Nach ältesten sortieren", "sort_oldest": "Nach ältesten sortieren (nur Dateien)",
"sort_largest": "Nach größten sortieren", "sort_largest": "Nach größten sortieren (nur Dateien)",
"sort_smallest": "Nach kleinsten sortieren", "sort_smallest": "Nach kleinsten sortieren (nur Dateien)",
"toggle_sidebar": "Seitenleiste umschalten", "toggle_sidebar": "Seitenleiste umschalten",
"go_to_homepage": "Zur Startseite", "go_to_homepage": "Zur Startseite",
"files": "Dateien", "files": "Dateien",

View File

@ -50,10 +50,10 @@
"collapse_all": "Collapse all folders", "collapse_all": "Collapse all folders",
"sort_a-z": "Sort A to Z", "sort_a-z": "Sort A to Z",
"sort_z-a": "Sort Z to A", "sort_z-a": "Sort Z to A",
"sort_newest": "Sort by newest", "sort_newest": "Sort by newest (files only)",
"sort_oldest": "Sort by oldest", "sort_oldest": "Sort by oldest (files only)",
"sort_largest": "Sort by largest", "sort_largest": "Sort by largest (files only)",
"sort_smallest": "Sort by smallest", "sort_smallest": "Sort by smallest (files only)",
"toggle_sidebar": "Toggle sidebar", "toggle_sidebar": "Toggle sidebar",
"go_to_homepage": "Go to homepage", "go_to_homepage": "Go to homepage",
"files": "Files", "files": "Files",

View File

@ -51,10 +51,10 @@
"collapse_all": "Collapse all folders", "collapse_all": "Collapse all folders",
"sort_a-z": "Sort A to Z", "sort_a-z": "Sort A to Z",
"sort_z-a": "Sort Z to A", "sort_z-a": "Sort Z to A",
"sort_newest": "Sort by newest", "sort_newest": "Sort by newest (files only)",
"sort_oldest": "Sort by oldest", "sort_oldest": "Sort by oldest (files only)",
"sort_largest": "Sort by largest", "sort_largest": "Sort by largest (files only)",
"sort_smallest": "Sort by smallest", "sort_smallest": "Sort by smallest (files only)",
"toggle_sidebar": "Toggle sidebar", "toggle_sidebar": "Toggle sidebar",
"go_to_homepage": "Go to homepage", "go_to_homepage": "Go to homepage",
"files": "Files", "files": "Files",

View File

@ -51,10 +51,10 @@
"collapse_all": "Contraer todas las carpetas", "collapse_all": "Contraer todas las carpetas",
"sort_a-z": "Ordenar A-Z", "sort_a-z": "Ordenar A-Z",
"sort_z-a": "Ordenar Z-A", "sort_z-a": "Ordenar Z-A",
"sort_newest": "Ordenar por más reciente", "sort_newest": "Ordenar por más reciente (solo archivos)",
"sort_oldest": "Ordenar por más antiguo", "sort_oldest": "Ordenar por más antiguo (solo archivos)",
"sort_largest": "Ordenar por más grande", "sort_largest": "Ordenar por más grande (solo archivos)",
"sort_smallest": "Ordenar por más pequeño", "sort_smallest": "Ordenar por más pequeño (solo archivos)",
"toggle_sidebar": "Alternar barra lateral", "toggle_sidebar": "Alternar barra lateral",
"go_to_homepage": "Ir al inicio", "go_to_homepage": "Ir al inicio",
"files": "Archivos", "files": "Archivos",

View File

@ -51,10 +51,10 @@
"collapse_all": "Réduire tous les dossiers", "collapse_all": "Réduire tous les dossiers",
"sort_a-z": "Trier A-Z", "sort_a-z": "Trier A-Z",
"sort_z-a": "Trier Z-A", "sort_z-a": "Trier Z-A",
"sort_newest": "Trier par plus récent", "sort_newest": "Trier par plus récent (fichiers uniquement)",
"sort_oldest": "Trier par plus ancien", "sort_oldest": "Trier par plus ancien (fichiers uniquement)",
"sort_largest": "Trier par plus grand", "sort_largest": "Trier par plus grand (fichiers uniquement)",
"sort_smallest": "Trier par plus petit", "sort_smallest": "Trier par plus petit (fichiers uniquement)",
"toggle_sidebar": "Afficher/Masquer la barre latérale", "toggle_sidebar": "Afficher/Masquer la barre latérale",
"go_to_homepage": "Aller à l'accueil", "go_to_homepage": "Aller à l'accueil",
"files": "Fichiers", "files": "Fichiers",

View File

@ -51,10 +51,10 @@
"collapse_all": "Mappák összecsukása", "collapse_all": "Mappák összecsukása",
"sort_a-z": "Rendezés A-Z", "sort_a-z": "Rendezés A-Z",
"sort_z-a": "Rendezés Z-A", "sort_z-a": "Rendezés Z-A",
"sort_newest": "Legújabb elöl", "sort_newest": "Legújabb elöl (csak fájlok)",
"sort_oldest": "Legrégebbi elöl", "sort_oldest": "Legrégebbi elöl (csak fájlok)",
"sort_largest": "Legnagyobb elöl", "sort_largest": "Legnagyobb elöl (csak fájlok)",
"sort_smallest": "Legkisebb elöl", "sort_smallest": "Legkisebb elöl (csak fájlok)",
"toggle_sidebar": "Oldalsáv megjelenítése", "toggle_sidebar": "Oldalsáv megjelenítése",
"go_to_homepage": "Ugrás a kezdőlapra", "go_to_homepage": "Ugrás a kezdőlapra",
"files": "Fájlok", "files": "Fájlok",

View File

@ -50,10 +50,10 @@
"collapse_all": "Comprimi tutte le cartelle", "collapse_all": "Comprimi tutte le cartelle",
"sort_a-z": "Ordina A-Z", "sort_a-z": "Ordina A-Z",
"sort_z-a": "Ordina Z-A", "sort_z-a": "Ordina Z-A",
"sort_newest": "Ordina per più recente", "sort_newest": "Ordina per più recente (solo file)",
"sort_oldest": "Ordina per più vecchio", "sort_oldest": "Ordina per più vecchio (solo file)",
"sort_largest": "Ordina per più grande", "sort_largest": "Ordina per più grande (solo file)",
"sort_smallest": "Ordina per più piccolo", "sort_smallest": "Ordina per più piccolo (solo file)",
"toggle_sidebar": "Mostra/nascondi barra laterale", "toggle_sidebar": "Mostra/nascondi barra laterale",
"go_to_homepage": "Vai alla home", "go_to_homepage": "Vai alla home",
"files": "File", "files": "File",

View File

@ -50,10 +50,10 @@
"collapse_all": "すべて折りたたむ", "collapse_all": "すべて折りたたむ",
"sort_a-z": "A-Z順", "sort_a-z": "A-Z順",
"sort_z-a": "Z-A順", "sort_z-a": "Z-A順",
"sort_newest": "新しい順", "sort_newest": "新しい順(ファイルのみ)",
"sort_oldest": "古い順", "sort_oldest": "古い順(ファイルのみ)",
"sort_largest": "大きい順", "sort_largest": "大きい順(ファイルのみ)",
"sort_smallest": "小さい順", "sort_smallest": "小さい順(ファイルのみ)",
"toggle_sidebar": "サイドバー切替", "toggle_sidebar": "サイドバー切替",
"go_to_homepage": "ホームへ", "go_to_homepage": "ホームへ",
"files": "ファイル", "files": "ファイル",

View File

@ -50,10 +50,10 @@
"collapse_all": "Свернуть все папки", "collapse_all": "Свернуть все папки",
"sort_a-z": "Сортировка А-Я", "sort_a-z": "Сортировка А-Я",
"sort_z-a": "Сортировка Я-А", "sort_z-a": "Сортировка Я-А",
"sort_newest": "Сначала новые", "sort_newest": "Сначала новые (только файлы)",
"sort_oldest": "Сначала старые", "sort_oldest": "Сначала старые (только файлы)",
"sort_largest": "Сначала большие", "sort_largest": "Сначала большие (только файлы)",
"sort_smallest": "Сначала маленькие", "sort_smallest": "Сначала маленькие (только файлы)",
"toggle_sidebar": "Показать/скрыть боковую панель", "toggle_sidebar": "Показать/скрыть боковую панель",
"go_to_homepage": "На главную", "go_to_homepage": "На главную",
"files": "Файлы", "files": "Файлы",

View File

@ -50,10 +50,10 @@
"collapse_all": "Strni vse mape", "collapse_all": "Strni vse mape",
"sort_a-z": "Razvrsti A-Ž", "sort_a-z": "Razvrsti A-Ž",
"sort_z-a": "Razvrsti Ž-A", "sort_z-a": "Razvrsti Ž-A",
"sort_newest": "Razvrsti po najnovejših", "sort_newest": "Razvrsti po najnovejših (samo datoteke)",
"sort_oldest": "Razvrsti po najstarejših", "sort_oldest": "Razvrsti po najstarejših (samo datoteke)",
"sort_largest": "Razvrsti po največjih", "sort_largest": "Razvrsti po največjih (samo datoteke)",
"sort_smallest": "Razvrsti po najmanjših", "sort_smallest": "Razvrsti po najmanjših (samo datoteke)",
"toggle_sidebar": "Preklopi stransko vrstico", "toggle_sidebar": "Preklopi stransko vrstico",
"go_to_homepage": "Pojdi na začetno stran", "go_to_homepage": "Pojdi na začetno stran",
"files": "Datoteke", "files": "Datoteke",

View File

@ -50,10 +50,10 @@
"collapse_all": "折叠所有文件夹", "collapse_all": "折叠所有文件夹",
"sort_a-z": "按A-Z排序", "sort_a-z": "按A-Z排序",
"sort_z-a": "按Z-A排序", "sort_z-a": "按Z-A排序",
"sort_newest": "按最新排序", "sort_newest": "按最新排序(仅文件)",
"sort_oldest": "按最旧排序", "sort_oldest": "按最旧排序(仅文件)",
"sort_largest": "按最大排序", "sort_largest": "按最大排序(仅文件)",
"sort_smallest": "按最小排序", "sort_smallest": "按最小排序(仅文件)",
"toggle_sidebar": "切换侧边栏", "toggle_sidebar": "切换侧边栏",
"go_to_homepage": "前往主页", "go_to_homepage": "前往主页",
"files": "文件", "files": "文件",