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>
@@ -0,0 +1,313 @@
<script>
import { renderMarkdown, isInternalLink, parseVerstakUrl, isAllowedVerstakType } from '../../markdown/markdown';
import { t } from '../../i18n';
export let content = '';
let html = '';
let error = '';
$: {
try {
html = renderMarkdown(content || '');
error = '';
} catch (e) {
error = String(e);
html = '';
}
}
function handleClick(e) {
const link = e.target.closest('[data-verstak-href]');
if (!link) return;
const href = link.getAttribute('data-verstak-href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
const parsed = parseVerstakUrl(href);
if (!parsed || !isAllowedVerstakType(parsed.type)) return;
link.dispatchEvent(new CustomEvent('verstak-link', {
bubbles: true,
detail: { type: parsed.type, id: parsed.id, href },
}));
}
</script>
<div class="markdown-body" on:click={handleClick} role="article">
{#if error}
<div class="md-error">
<p>⚠️ {t('note.preview.error')}</p>
<p class="md-error-detail">{error}</p>
</div>
{:else if !content?.trim()}
<div class="md-empty">{t('note.preview.empty')}</div>
{:else}
{@html html}
{/if}
</div>
<style>
.markdown-body {
color: #e4e4ef;
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 720px;
}
.md-empty {
color: #555;
font-style: italic;
padding: 24px 0;
}
.md-error {
background: #3a2222;
border: 1px solid #5a2a2a;
border-radius: 8px;
padding: 16px;
color: #ff8888;
}
.md-error-detail {
font-size: 12px;
color: #cc6666;
margin-top: 8px;
}
/* Headings */
:global(.markdown-body h1) {
font-size: 28px;
font-weight: 700;
margin: 32px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #2a2a3c;
color: #f0f0f8;
}
:global(.markdown-body h2) {
font-size: 22px;
font-weight: 600;
margin: 28px 0 14px;
padding-bottom: 6px;
border-bottom: 1px solid #22223a;
color: #e8e8f0;
}
:global(.markdown-body h3) {
font-size: 18px;
font-weight: 600;
margin: 24px 0 12px;
color: #ddd;
}
:global(.markdown-body h4) {
font-size: 16px;
font-weight: 600;
margin: 20px 0 10px;
color: #ccc;
}
:global(.markdown-body h5),
:global(.markdown-body h6) {
font-size: 14px;
font-weight: 600;
margin: 16px 0 8px;
color: #bbb;
}
/* Paragraphs */
:global(.markdown-body p) {
margin: 12px 0;
}
/* Links */
:global(.markdown-body .md-link) {
color: #818cf8;
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s, color 0.15s;
cursor: pointer;
}
:global(.markdown-body .md-link:hover) {
color: #a5b4fc;
border-bottom-color: #818cf8;
}
:global(.markdown-body .md-link--internal) {
color: #67e8f9;
font-weight: 500;
}
:global(.markdown-body .md-link--internal:hover) {
color: #a5f3fc;
border-bottom-color: #67e8f9;
}
:global(.markdown-body .md-link--blocked) {
color: #666;
text-decoration: line-through;
cursor: not-allowed;
}
/* Bold / Italic / Strikethrough */
:global(.markdown-body strong) { font-weight: 700; color: #f0f0f8; }
:global(.markdown-body em) { font-style: italic; }
:global(.markdown-body del) { text-decoration: line-through; color: #888; }
/* Lists */
:global(.markdown-body ul) {
padding-left: 24px;
margin: 12px 0;
list-style-type: disc;
}
:global(.markdown-body ol) {
padding-left: 24px;
margin: 12px 0;
list-style-type: decimal;
}
:global(.markdown-body li) {
margin: 4px 0;
line-height: 1.6;
}
:global(.markdown-body li > ul),
:global(.markdown-body li > ol) {
margin: 4px 0;
}
/* Task lists */
:global(.markdown-body .md-task-item) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0;
}
:global(.markdown-body .md-task-checkbox) {
margin-top: 3px;
flex-shrink: 0;
accent-color: #818cf8;
}
:global(.markdown-body .md-task-text) {
flex: 1;
}
/* Blockquote */
:global(.markdown-body blockquote) {
margin: 16px 0;
padding: 12px 16px;
border-left: 3px solid #818cf8;
background: rgba(129, 140, 248, 0.06);
border-radius: 0 6px 6px 0;
color: #bbb;
font-style: italic;
}
:global(.markdown-body blockquote p) {
margin: 4px 0;
}
/* Inline code */
:global(.markdown-body .md-code-inline) {
background: #1e1e38;
color: #f59e0b;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
}
/* Code blocks — dark theme */
:global(.markdown-body .md-code-block) {
background: #0d1117;
border: 1px solid #21262d;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
overflow-x: auto;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.55;
color: #c9d1d9;
}
:global(.markdown-body .md-code-block code) {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
/* highlight.js dark theme (GitHub Dark) */
:global(.markdown-body .hljs) { color: #c9d1d9; }
:global(.markdown-body .hljs-comment) { color: #8b949e; }
:global(.markdown-body .hljs-keyword) { color: #ff7b72; }
:global(.markdown-body .hljs-string) { color: #a5d6ff; }
:global(.markdown-body .hljs-number) { color: #79c0ff; }
:global(.markdown-body .hljs-function) { color: #d2a8ff; }
:global(.markdown-body .hljs-class) { color: #ffa657; }
:global(.markdown-body .hljs-variable) { color: #ffa657; }
:global(.markdown-body .hljs-operator) { color: #ff7b72; }
:global(.markdown-body .hljs-punctuation) { color: #c9d1d9; }
:global(.markdown-body .hljs-tag) { color: #7ee787; }
:global(.markdown-body .hljs-attr) { color: #79c0ff; }
:global(.markdown-body .hljs-title) { color: #d2a8ff; }
:global(.markdown-body .hljs-built_in) { color: #ffa657; }
:global(.markdown-body .hljs-literal) { color: #79c0ff; }
:global(.markdown-body .hljs-type) { color: #ffa657; }
:global(.markdown-body .hljs-params) { color: #c9d1d9; }
:global(.markdown-body .hljs-property) { color: #79c0ff; }
:global(.markdown-body .hljs-regexp) { color: #a5d6ff; }
:global(.markdown-body .hljs-meta) { color: #8b949e; }
:global(.markdown-body .hljs-doctag) { color: #ff7b72; }
:global(.markdown-body .hljs-name) { color: #7ee787; }
:global(.markdown-body .hljs-section) { color: #1f6feb; font-weight: 700; }
:global(.markdown-body .hljs-symbol) { color: #79c0ff; }
:global(.markdown-body .hljs-bullet) { color: #f2cc60; }
:global(.markdown-body .hljs-code) { color: #c9d1d9; }
:global(.markdown-body .hljs-emphasis) { font-style: italic; }
:global(.markdown-body .hljs-strong) { font-weight: 700; }
:global(.markdown-body .hljs-addition) { color: #aff5b4; background: #033a16; }
:global(.markdown-body .hljs-deletion) { color: #ffdcd7; background: #67060c; }
/* Tables */
:global(.markdown-body table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
}
:global(.markdown-body thead) {
background: #1a1a2e;
}
:global(.markdown-body th) {
padding: 10px 14px;
text-align: left;
font-weight: 600;
color: #c4c4d4;
border-bottom: 2px solid #2a2a3c;
}
:global(.markdown-body td) {
padding: 8px 14px;
border-bottom: 1px solid #1e1e30;
}
:global(.markdown-body tbody tr:hover) {
background: rgba(129, 140, 248, 0.04);
}
/* Horizontal rule */
:global(.markdown-body hr) {
border: none;
border-top: 1px solid #2a2a3c;
margin: 24px 0;
}
/* Blocked images placeholder */
:global(.markdown-body .md-image-blocked) {
display: inline-block;
background: #1a1a2e;
border: 1px dashed #3a3a5c;
border-radius: 6px;
padding: 8px 14px;
color: #666;
font-size: 13px;
font-style: italic;
margin: 8px 0;
}
</style>
@@ -0,0 +1,178 @@
<script>
import { createEventDispatcher } from 'svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import MarkdownPreview from './MarkdownPreview.svelte';
import { t } from '../../i18n';
export let content = '';
export let viewMode = 'edit';
export let placeholder = '';
const dispatch = createEventDispatcher();
function setMode(mode) {
dispatch('mode-change', { mode });
}
function handleContentChange(e) {
content = e.detail.content;
dispatch('content-change', e.detail);
}
function handleSave() {
dispatch('save');
}
function handleInsertLink() {
dispatch('insert-link');
}
function handleInsertInternalLink() {
dispatch('insert-internal-link');
}
function handleVerstakLink(e) {
dispatch('verstak-link', e.detail);
}
</script>
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
<!-- Mode switcher -->
<div class="mode-switcher" role="tablist" aria-label="Note view mode">
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'edit'} class:active={viewMode === 'edit'} on:click={() => setMode('edit')}>
{t('note.mode.edit')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'preview'} class:active={viewMode === 'preview'} on:click={() => setMode('preview')}>
{t('note.mode.preview')}
</button>
<button type="button" class="mode-btn" role="tab" aria-selected={viewMode === 'split'} class:active={viewMode === 'split'} on:click={() => setMode('split')}>
{t('note.mode.split')}
</button>
</div>
<!-- Content area -->
<div class="panel-content">
{#if viewMode === 'edit'}
<MarkdownEditor
{content}
{placeholder}
viewMode="edit"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
{:else if viewMode === 'preview'}
<div class="preview-pane">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
{:else if viewMode === 'split'}
<div class="split-pane">
<div class="split-editor">
<MarkdownEditor
{content}
{placeholder}
viewMode="split"
on:content-change={handleContentChange}
on:save={handleSave}
on:insert-link={handleInsertLink}
on:insert-internal-link={handleInsertInternalLink}
/>
</div>
<div class="split-preview">
<MarkdownPreview {content} on:verstak-link={handleVerstakLink} />
</div>
</div>
{/if}
</div>
</div>
<style>
.note-editor-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
}
.mode-switcher {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 12px;
border-bottom: 1px solid #2a2a3c;
background: #14141f;
flex-shrink: 0;
}
.mode-btn {
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #888;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.mode-btn:hover {
color: #ccc;
background: #1e1e30;
}
.mode-btn.active {
color: #e4e4ef;
background: #22223a;
border-color: #333350;
font-weight: 500;
}
.mode-btn:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 1px;
}
.panel-content {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.mode-preview .panel-content {
overflow-y: auto;
}
.preview-pane {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.split-pane {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.split-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #2a2a3c;
overflow: hidden;
}
.split-preview {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 20px 24px;
background: #11111c;
}
</style>
@@ -0,0 +1,337 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { t } from '../../i18n';
export let visible = false;
export let objectType = 'note'; // 'case' | 'note' | 'file' | 'secret'
const dispatch = createEventDispatcher();
let searchQuery = '';
let results = [];
let loading = false;
let error = '';
let selectedIndex = 0;
let inputRef;
const objectTypes = [
{ id: 'case', label: 'Дело', searchable: true },
{ id: 'note', label: 'Заметка', searchable: true },
{ id: 'file', label: 'Файл', searchable: true },
{ id: 'secret', label: 'Секрет', searchable: false },
];
$: currentTypeInfo = objectTypes.find(o => o.id === objectType) || objectTypes[0];
function wailsCall(method, ...args) {
try {
if (window['go'] && window['go']['main'] && window['go']['main']['App']) {
const fn = window['go']['main']['App'][method];
if (typeof fn === 'function') {
return fn(...args);
}
}
} catch (e) {
console.error('Wails call error:', method, e);
}
return Promise.reject(new Error('Wails not connected: ' + method));
}
async function search() {
if (!searchQuery.trim() || !currentTypeInfo.searchable) {
results = [];
return;
}
loading = true;
error = '';
try {
let items = [];
if (objectType === 'note') {
// Search across all notes
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'note' || n.type === 'case');
} else if (objectType === 'case') {
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'case' || n.type === 'project' || n.type === 'client');
} else if (objectType === 'file') {
const searchResults = await wailsCall('SearchNodes', searchQuery.trim());
items = (searchResults || []).filter(n => n.type === 'file');
}
results = items;
selectedIndex = 0;
} catch (e) {
error = String(e);
results = [];
} finally {
loading = false;
}
}
function handleTypeChange(newType) {
objectType = newType;
searchQuery = '';
results = [];
error = '';
if (inputRef) inputRef.focus();
}
function handleSelect(item) {
dispatch('select', { type: objectType, id: item.id, title: item.title, path: item.path || '' });
}
function handleKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
dispatch('close');
}
}
function handleInput() {
search();
}
$: if (visible && inputRef) {
setTimeout(() => inputRef.focus(), 50);
}
</script>
{#if visible}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label="Select object" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
<div class="modal-box object-picker-modal" role="document">
<h3>Вставить внутреннюю ссылку</h3>
<!-- Object type selector -->
<div class="type-selector" role="tablist" aria-label="Object type">
{#each objectTypes as type}
<button
type="button"
class="type-btn"
class:active={objectType === type.id}
class:disabled={!type.searchable}
role="tab"
aria-selected={objectType === type.id}
on:click={() => type.searchable && handleTypeChange(type.id)}
disabled={!type.searchable}
>
{type.label}
</button>
{/each}
</div>
{#if !currentTypeInfo.searchable}
<div class="coming-soon">
<p>{t('note.internal.secretNotImplemented')}</p>
<p class="hint">Object picker for this type is coming soon.</p>
</div>
{:else}
<!-- Search input -->
<div class="search-wrapper">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
bind:this={inputRef}
bind:value={searchQuery}
on:input={handleInput}
on:keydown={handleKeydown}
placeholder="Поиск..."
class="search-input"
aria-label="Search objects"
aria-autocomplete="list"
aria-controls="object-picker-results"
aria-activedescendant={results[selectedIndex] ? `result-${selectedIndex}` : null}
/>
</div>
<!-- Results -->
<div class="results-list" id="object-picker-results" role="listbox">
{#if loading}
<div class="results-loading">{t('common.loading')}</div>
{:else if error}
<div class="results-error">{error}</div>
{:else if searchQuery.trim() && results.length === 0}
<div class="results-empty">{t('search.noResults')}</div>
{:else}
{#each results as item, i}
<button
type="button"
class="result-item"
class:selected={i === selectedIndex}
id="result-{i}"
role="option"
aria-selected={i === selectedIndex}
on:click={() => handleSelect(item)}
>
<span class="result-title">{item.title}</span>
{#if item.path}
<span class="result-path">{item.path}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
<div class="modal-actions">
<button type="button" class="btn" on:click={() => dispatch('close')}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
<style>
.object-picker-modal {
width: 480px;
max-width: 95vw;
}
.type-selector {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.type-btn {
padding: 5px 12px;
border: 1px solid #2a2a3c;
border-radius: 4px;
background: #1a1a28;
color: #999;
font-size: 12px;
font-family: inherit;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.type-btn:hover:not(:disabled) {
background: #22223a;
color: #ccc;
}
.type-btn.active {
background: #2a2a4a;
color: #e4e4ef;
border-color: #6366f1;
}
.type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.search-wrapper {
position: relative;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #666;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 32px;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #13131f;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.search-input:focus {
border-color: #818cf8;
}
.results-list {
max-height: 240px;
overflow-y: auto;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #11111c;
}
.results-loading,
.results-error,
.results-empty {
padding: 16px;
text-align: center;
color: #666;
font-size: 13px;
}
.results-error {
color: #ff8888;
}
.result-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 12px;
border: none;
border-bottom: 1px solid #1a1a28;
background: transparent;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover,
.result-item.selected {
background: #1e1e38;
}
.result-title {
font-weight: 500;
}
.result-path {
font-size: 11px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coming-soon {
padding: 24px;
text-align: center;
color: #666;
}
.coming-soon .hint {
font-size: 12px;
color: #555;
margin-top: 8px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
</style>