1. Fix RU_TO_EN keyboard map in keyboardLayout.ts: - ч was mapped to 'c' (wrong), now correctly 'x' - с was mapped to 'v' (wrong), now correctly 'c' - ю was mapped to '.' (wrong), now correctly ',' - Full rewrite with standard QWERTY/ЙЦУКЕН positional mapping - Examples: dthcnfr→верстак, руддщ→hello, цщкдв→world all correct now 2. Root cause of search breaking after 3-4 chars: Old map had ч:'c', с:'v' swapped. So dthcnfr → 'верчиак' (wrong). Each character was mapped to wrong Cyrillic equivalent. 3. Add unit tests: keyboardLayout.test.js (39 tests, node-runner): - EN→RU: dthcnfr, ghbdtn, ntcn - RU→EN: руддщ, цщкдв, ышеш - Unicode safety: Latin c (U+0063) ≠ Cyrillic с (U+0441) - expandKeyboardVariants for mixed inputs - Edge cases: empty, single char, mixed case, numbers 4. InternalLinkPicker type tabs → filters (not search modes): - Store rawResults (all) + filtered results by activeType - Switching type tab no longer clears query or triggers new search - Just filters existing rawResults by selected type - Shows 'Нет результатов для этого типа' when filtered empty 5. Both GlobalSearch and InternalLinkPicker use same expandKeyboardVariants() All tests PASS, full build OK.
349 lines
8.4 KiB
Svelte
349 lines
8.4 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 rawResults = []; // all search results (unfiltered)
|
|
let results = []; // filtered by activeType
|
|
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';
|
|
}
|
|
|
|
// Filter rawResults by activeType into displayed results
|
|
function applyTypeFilter() {
|
|
results = rawResults.filter(n => nodeTypeToFilter(n.type) === activeType);
|
|
selectedIndex = 0;
|
|
}
|
|
|
|
async function search() {
|
|
if (!query.trim() || query.trim().length < 2) {
|
|
rawResults = [];
|
|
results = [];
|
|
return;
|
|
}
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
// Expand query with keyboard layout variants for tolerant search
|
|
const variants = expandKeyboardVariants(query.trim());
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
rawResults = Array.from(merged.values());
|
|
applyTypeFilter();
|
|
} catch (e) {
|
|
error = String(e);
|
|
rawResults = [];
|
|
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;
|
|
applyTypeFilter();
|
|
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 && !loading}
|
|
<div class="picker-empty">
|
|
{rawResults.length > 0 ? 'Нет результатов для этого типа' : 'Ничего не найдено'}
|
|
</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>
|