Replace broken ObjectPickerModal and manual modal with proper InternalLinkPicker component: - Search field with debounced SearchNodes API calls - Type tabs: Дело, Заметка, Файл, Секрет (disabled) - Results list showing title + path, keyboard navigation - Inserts [Title](verstak://type/id) at cursor position - No layout breakage — picker is a normal modal via position:fixed - Escape/Cancel close picker cleanly - bind:this on NoteEditorPanel → MarkdownEditor.insertText() Also: - MarkdownEditor: added public insertText() method + bind:this - NoteEditorPanel: added bind:this on MarkdownEditor + public insertText() - Removed manual modal, insertInternalLinkMarkdown(), document.querySelector
391 lines
15 KiB
Svelte
391 lines
15 KiB
Svelte
<script>
|
|
import { createEventDispatcher } from 'svelte';
|
|
import { t } from '../../i18n';
|
|
|
|
export let content = '';
|
|
export let viewMode = 'edit';
|
|
export let placeholder = '';
|
|
|
|
const dispatch = createEventDispatcher();
|
|
let textareaRef;
|
|
|
|
// ─── Public API: insert text at cursor ──────────────────────────
|
|
export function insertText(text) {
|
|
const ta = textareaRef;
|
|
if (!ta) return;
|
|
const start = ta.selectionStart;
|
|
const end = ta.selectionEnd;
|
|
const before = content.substring(0, start);
|
|
const after = content.substring(end);
|
|
const newContent = before + text + after;
|
|
content = newContent;
|
|
dispatch('content-change', { content: newContent });
|
|
requestAnimationFrame(() => {
|
|
ta.focus();
|
|
ta.selectionStart = ta.selectionEnd = start + text.length;
|
|
});
|
|
}
|
|
|
|
// ─── 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>
|