342 lines
8.1 KiB
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>
|