fix: stabilize markdown notes — internal link modal, rename UI, trash integration

- Replace broken ObjectPickerModal with simple inline modal (Label+URL fields)
- Insert internal link at cursor position in textarea
- Add rename button in note editor header and note cards
- Add delete button on note cards with confirm dialog
- Integrate DeleteNote with shared trash (.verstak/trash/) via files.TrashFile()
- Remove hidden .verstak/trash/notes/ folder — notes use unified trash now
- Fix purgeTrashNode to clean file-record-based trash entries (notes/files)
- Add activity + sync ops to DeleteNote binding
- Add files.TrashFile() public method
- Update i18n keys for note.rename, note.deleteConfirm, internal link modal
- AssertContained: symlink-aware path containment check
- Update tests: shared trash, file record missing flag, collision on rename
- All go test ./... pass, frontend build passes, GUI binary built
This commit is contained in:
2026-06-15 09:19:26 +08:00
parent a193c5a4c6
commit 0fdf77ce03
27 changed files with 2892 additions and 39 deletions
@@ -0,0 +1,373 @@
<script>
import { createEventDispatcher } from 'svelte';
import { t } from '../../i18n';
export let content = '';
export let viewMode = 'edit';
export let placeholder = '';
const dispatch = createEventDispatcher();
let textareaRef;
// ─── Core insert helper ──────────────────────────────────────────
function insertAtCursor(before, after = '', placeholder = '') {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = content.substring(start, end);
const insertText = selected || placeholder;
const replacement = before + insertText + after;
const newContent = content.substring(0, start) + replacement + content.substring(end);
content = newContent;
// Dispatch change
dispatch('content-change', { content: newContent });
// Restore focus and set cursor position
requestAnimationFrame(() => {
ta.focus();
if (selected) {
// Wrap mode: select the inserted content (excluding markers)
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + selected.length;
} else {
// No selection: place cursor inside the wrapper
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + placeholder.length;
}
});
}
function insertLinePrefix(prefix) {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
// Find the start of the line(s) covered by selection
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const selectedLines = content.substring(lineStart, lineEnd);
const lines = selectedLines.split('\n');
const prefixedLines = lines.map(l => {
// Don't double-prefix
if (l.startsWith(prefix)) return l;
return prefix + l;
});
const replacement = prefixedLines.join('\n');
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + replacement.length;
});
}
// ─── Toolbar handlers ────────────────────────────────────────────
function handleBold() { insertAtCursor('**', '**', 'bold text'); }
function handleItalic() { insertAtCursor('*', '*', 'italic text'); }
function handleInlineCode() { insertAtCursor('`', '`', 'code'); }
function handleLink() { dispatch('insert-link'); }
function handleHeading() { insertLinePrefix('## '); }
function handleQuote() { insertLinePrefix('> '); }
function handleBulletList() { insertLinePrefix('- '); }
function handleNumberedList() {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = ta.selectionEnd;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const selectedLines = content.substring(lineStart, lineEnd);
const lines = selectedLines.split('\n');
let counter = 1;
const prefixedLines = lines.map(l => {
const trimmed = l.trim();
// Remove existing numbered list prefix
const cleaned = l.replace(/^\s*\d+\.\s*/, '');
return `${counter++}. ${trimmed || 'item'}`;
});
const replacement = prefixedLines.join('\n');
const newContent = content.substring(0, lineStart) + replacement + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => { ta.focus(); ta.selectionStart = lineStart; ta.selectionEnd = lineStart + replacement.length; });
}
function handleCheckbox() { insertLinePrefix('- [ ] '); }
function handleCodeBlock() {
const ta = textareaRef;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = content.substring(start, end);
const replacement = selected
? '```\n' + selected + '\n```'
: '```\ncode here\n```';
const newContent = content.substring(0, start) + replacement + content.substring(end);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
if (selected) {
ta.selectionStart = start + 4;
ta.selectionEnd = start + 4 + selected.length;
} else {
ta.selectionStart = start + 4;
ta.selectionEnd = start + 4 + 'code here'.length;
}
});
}
function handleInternalLink() { dispatch('insert-internal-link'); }
// ─── Textarea events ─────────────────────────────────────────────
function handleInput(e) {
content = e.target.value;
dispatch('content-change', { content });
}
function handleKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
dispatch('save');
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const ta = e.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (e.shiftKey) {
// Unindent: remove up to 2 spaces at start of selected lines
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const block = content.substring(lineStart, lineEnd);
const dedented = block.replace(/^ {1,2}/gm, '');
const newContent = content.substring(0, lineStart) + dedented + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + dedented.length;
});
} else {
// Indent: insert 2 spaces at cursor or start of each selected line
if (start === end) {
// No selection: insert 2 spaces at cursor
const newContent = content.substring(0, start) + ' ' + content.substring(end);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = ta.selectionEnd = start + 2;
});
} else {
// Selection: indent each line
let lineStart = start;
while (lineStart > 0 && content[lineStart - 1] !== '\n') lineStart--;
let lineEnd = end;
while (lineEnd < content.length && content[lineEnd] !== '\n') lineEnd++;
const block = content.substring(lineStart, lineEnd);
const indented = block.replace(/^/gm, ' ');
const newContent = content.substring(0, lineStart) + indented + content.substring(lineEnd);
content = newContent;
dispatch('content-change', { content: newContent });
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = lineStart;
ta.selectionEnd = lineStart + indented.length;
});
}
}
}
}
</script>
<div class="md-editor" class:mode-edit={viewMode === 'edit' || viewMode === 'split'}>
<!-- Toolbar -->
<div class="md-toolbar" role="toolbar" aria-label="Markdown formatting">
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleBold} title={t('note.toolbar.bold')} aria-label={t('note.toolbar.bold')}>
<span class="md-btn-label md-btn-bold">B</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleItalic} title={t('note.toolbar.italic')} aria-label={t('note.toolbar.italic')}>
<span class="md-btn-label md-btn-italic">I</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleHeading} title={t('note.toolbar.heading')} aria-label={t('note.toolbar.heading')}>
<span class="md-btn-label">H</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleLink} title={t('note.toolbar.link')} aria-label={t('note.toolbar.link')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleInlineCode} title={t('note.toolbar.code')} aria-label={t('note.toolbar.code')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleCodeBlock} title={t('note.toolbar.codeBlock')} aria-label={t('note.toolbar.codeBlock')}>
<span class="md-btn-label md-btn-sm">{'</>'}</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn" on:click={handleBulletList} title={t('note.toolbar.bulletList')} aria-label={t('note.toolbar.bulletList')}>
<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="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleNumberedList} title={t('note.toolbar.numberedList')} aria-label={t('note.toolbar.numberedList')}>
<span class="md-btn-label md-btn-sm">1.</span>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleCheckbox} title={t('note.toolbar.checkbox')} aria-label={t('note.toolbar.checkbox')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</button>
<button type="button" class="md-toolbar-btn" on:click={handleQuote} title={t('note.toolbar.quote')} aria-label={t('note.toolbar.quote')}>
<span class="md-btn-label md-btn-sm md-btn-quote">"</span>
</button>
</div>
<div class="md-toolbar-sep"></div>
<div class="md-toolbar-group">
<button type="button" class="md-toolbar-btn md-toolbar-btn--internal" on:click={handleInternalLink} title={t('note.toolbar.internalLink')} aria-label={t('note.toolbar.internalLink')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M4.93 4.93l1.41 1.41"/><path d="M17.66 17.66l1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M6.34 17.66l-1.41 1.41"/><path d="M19.07 4.93l-1.41 1.41"/></svg>
</button>
</div>
</div>
<!-- Editor textarea -->
<textarea
class="md-textarea"
bind:value={content}
bind:this={textareaRef}
{placeholder}
on:input={handleInput}
on:keydown={handleKeydown}
spellcheck="true"
></textarea>
</div>
<style>
.md-editor {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.md-editor.mode-edit {
height: auto;
}
/* Toolbar */
.md-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 10px;
border-bottom: 1px solid #2a2a3c;
background: #16161f;
flex-shrink: 0;
flex-wrap: wrap;
}
.md-toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.md-toolbar-sep {
width: 1px;
height: 18px;
background: #2a2a3c;
margin: 0 4px;
flex-shrink: 0;
}
.md-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #999;
cursor: pointer;
transition: background 0.12s, color 0.12s;
padding: 0;
}
.md-toolbar-btn:hover {
background: #2a2a3c;
color: #e4e4ef;
}
.md-toolbar-btn:active {
background: #333350;
}
.md-toolbar-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.md-toolbar-btn--internal {
color: #67e8f9;
}
.md-toolbar-btn--internal:hover {
background: rgba(103, 232, 249, 0.1);
color: #a5f3fc;
}
.md-btn-label {
font-size: 13px;
font-weight: 700;
font-family: 'SF Mono', 'Fira Code', monospace;
line-height: 1;
}
.md-btn-bold { font-weight: 900; }
.md-btn-italic { font-style: italic; font-family: Georgia, serif; }
.md-btn-sm { font-size: 10px; }
.md-btn-quote { font-size: 16px; font-family: Georgia, serif; }
/* Textarea */
.md-textarea {
flex: 1;
width: 100%;
min-height: 0;
border: none;
outline: none;
background: #13131f;
color: #e4e4ef;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.65;
padding: 16px 20px;
resize: none;
tab-size: 2;
overflow-y: auto;
}
.md-textarea::placeholder {
color: #444;
font-style: italic;
}
</style>