verstak/frontend/src/lib/components/notes/InternalLinkPicker.svelte

342 lines
8.1 KiB
Svelte

<script>
import { createEventDispatcher } from 'svelte';
import { escapeMarkdownLabel } from '../../markdown/markdown';
import { expandKeyboardVariants } from '../../util/keyboardLayout';
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) {
if (type === 'note') return 'note';
if (type === 'file') return 'file';
if (type === 'secret') return 'secret';
return 'case';
}
async function search() {
if (!query.trim() || query.trim().length < 2) {
results = [];
return;
}
loading = true;
error = '';
try {
// Expand query with keyboard layout variants for tolerant search
const variants = expandKeyboardVariants(query.trim());
// Deduplicate: skip variants identical to the original query's lowercase
const seen = new Set();
const queries = [];
for (const v of variants) {
if (!seen.has(v)) {
seen.add(v);
queries.push(v);
}
}
// Execute all variant queries in parallel
const allResults = await Promise.all(
queries.map(q => window['go']['main']['App']['SearchNodes'](q).catch(() => []))
);
// Merge and deduplicate by node ID
const merged = new Map();
for (const arr of allResults) {
for (const n of (arr || [])) {
if (!merged.has(n.id)) {
merged.set(n.id, n);
}
}
}
// Filter by active type
results = Array.from(merged.values()).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 label = escapeMarkdownLabel(item.title || item.id);
const md = `[${label}](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>