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:
@@ -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>
|
||||
Reference in New Issue
Block a user