feat: full Internal Link Picker with search and type filter

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
This commit is contained in:
2026-06-15 09:57:11 +08:00
parent 39d3b82199
commit 7521eea109
8 changed files with 461 additions and 157 deletions
@@ -0,0 +1,320 @@
<script>
import { createEventDispatcher } from 'svelte';
export let visible = false;
const dispatch = createEventDispatcher();
// Filter types
const types = [
{ id: 'case', label: 'Дело' },
{ id: 'note', label: 'Заметка' },
{ id: 'file', label: 'Файл' },
{ id: 'secret', label: 'Секрет' },
];
let activeType = 'note';
let query = '';
let results = [];
let selectedIndex = 0;
let loading = false;
let error = '';
let inputRef;
// Map node types from backend to our filter types
function nodeTypeToFilter(type) {
// case, project, client, document, recipe, space → case
// note → note
// file → file
// secret → secret
if (type === 'note') return 'note';
if (type === 'file') return 'file';
if (type === 'secret') return 'secret';
// Everything else goes to "case"
return 'case';
}
async function search() {
if (!query.trim() || query.trim().length < 2) {
results = [];
return;
}
loading = true;
error = '';
try {
const res = await window['go']['main']['App']['SearchNodes'](query.trim()) || [];
// Filter by active type
results = res.filter(n => nodeTypeToFilter(n.type) === activeType);
selectedIndex = 0;
} catch (e) {
error = String(e);
results = [];
} finally {
loading = false;
}
}
function handleInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(search, 250);
}
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]) {
selectResult(results[selectedIndex]);
}
} else if (e.key === 'Escape') {
dispatch('close');
}
}
function selectResult(item) {
const md = `[${item.title}](verstak://${nodeTypeToFilter(item.type)}/${item.id})`;
dispatch('insert', { markdown: md });
}
function handleTypeChange(typeId) {
activeType = typeId;
query = '';
results = [];
error = '';
if (inputRef) inputRef.focus();
}
let searchTimer;
$: if (visible && inputRef) {
setTimeout(() => inputRef.focus(), 50);
}
</script>
{#if visible}
<div class="picker-modal-overlay" on:click|self={() => dispatch('close')} on:keydown={(e) => e.key === 'Escape' && dispatch('close')}>
<div class="picker-modal" role="dialog" aria-modal="true" aria-label="Выбрать объект">
<div class="picker-header">
<input
type="text"
bind:this={inputRef}
bind:value={query}
on:input={handleInput}
on:keydown={handleKeydown}
placeholder="Поиск..."
class="picker-search-input"
autocomplete="off"
/>
</div>
<div class="picker-types" role="tablist" aria-label="Тип объекта">
{#each types as type}
<button
type="button"
class="picker-type-btn"
class:active={activeType === type.id}
class:disabled={type.id === 'secret'}
role="tab"
aria-selected={activeType === type.id}
on:click={() => type.id !== 'secret' && handleTypeChange(type.id)}
disabled={type.id === 'secret'}
>
{type.label}
</button>
{/each}
</div>
{#if activeType === 'secret'}
<div class="picker-secret-placeholder">
<p>🔒 Сейф доступов ещё не реализован</p>
</div>
{:else}
<div class="picker-results" role="listbox">
{#if loading}
<div class="picker-loading">Загрузка...</div>
{:else if error}
<div class="picker-error">{error}</div>
{:else if query.trim().length >= 2 && results.length === 0}
<div class="picker-empty">Ничего не найдено</div>
{:else}
{#each results as item, i}
<button
type="button"
class="picker-result"
class:selected={i === selectedIndex}
role="option"
aria-selected={i === selectedIndex}
on:click={() => selectResult(item)}
>
<span class="picker-result-title">{item.title}</span>
{#if item.path}
<span class="picker-result-path">{item.path}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
<div class="picker-footer">
<button type="button" class="btn" on:click={() => dispatch('close')}>Отмена</button>
</div>
</div>
</div>
{/if}
<style>
.picker-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.picker-modal {
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 12px;
width: 480px;
max-width: 90vw;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
padding: 12px 16px;
border-bottom: 1px solid #2a2a3c;
}
.picker-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #2a2a3c;
border-radius: 6px;
background: #13131f;
color: #e4e4ef;
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.picker-search-input:focus {
border-color: #818cf8;
}
.picker-types {
display: flex;
gap: 4px;
padding: 8px 16px;
border-bottom: 1px solid #2a2a3c;
}
.picker-type-btn {
padding: 4px 12px;
border: 1px solid #2a2a3c;
border-radius: 4px;
background: #13131f;
color: #999;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.picker-type-btn:hover:not(:disabled) {
background: #1e1e38;
color: #ccc;
}
.picker-type-btn.active {
background: #2a2a4a;
color: #e4e4ef;
border-color: #6366f1;
}
.picker-type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.picker-secret-placeholder {
padding: 32px;
text-align: center;
color: #666;
font-size: 14px;
}
.picker-results {
flex: 1;
overflow-y: auto;
min-height: 0;
max-height: 300px;
}
.picker-loading,
.picker-error,
.picker-empty {
padding: 24px;
text-align: center;
color: #666;
font-size: 13px;
}
.picker-error {
color: #ff8888;
}
.picker-result {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 16px;
border: none;
border-bottom: 1px solid #1e1e30;
background: transparent;
color: #e4e4ef;
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.picker-result:last-child {
border-bottom: none;
}
.picker-result:hover,
.picker-result.selected {
background: #1e1e38;
}
.picker-result-title {
font-weight: 500;
}
.picker-result-path {
font-size: 11px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #2a2a3c;
}
</style>