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:
mirivlad 2026-06-15 09:57:11 +08:00
parent 39d3b82199
commit 7521eea109
8 changed files with 461 additions and 157 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-6mFhgd0M.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bmXj_j_Z.css">
<script type="module" crossorigin src="/assets/main-C5mYAmha.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head>
<body>
<div id="app"></div>

View File

@ -17,6 +17,7 @@
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
// ===== Wails v2 API call helper =====
function wailsCall(method, ...args) {
@ -77,14 +78,12 @@
let activeTab = 'overview'
let notes = []
let noteEditor = null
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
let noteViewMode = 'edit'
let showLinkModal = false
let linkModalLabel = ''
let linkModalUrl = ''
// Simple internal link modal state (replaces broken ObjectPickerModal)
let showInternalLinkModal = false
let internalLinkLabel = ''
let internalLinkUrl = 'verstak://secret/sec_example'
let showInternalLinkPicker = false
let showVerstakToast = false
let verstakToastMessage = ''
// Note rename state
@ -1355,21 +1354,21 @@
showLinkModal = false
}
// ===== Simple internal link modal =====
function openInternalLinkModal() {
internalLinkLabel = ''
internalLinkUrl = 'verstak://secret/sec_example'
showInternalLinkModal = true
// ===== Internal link picker =====
function openInternalLinkPicker() {
showInternalLinkPicker = true;
}
function submitInternalLink() {
insertInternalLinkMarkdown()
function handleInternalLinkPickerInsert(e) {
const md = e.detail.markdown;
if (noteEditorPanel && typeof noteEditorPanel.insertText === 'function') {
noteEditorPanel.insertText(md);
}
showInternalLinkPicker = false;
}
function cancelInternalLink() {
showInternalLinkModal = false
internalLinkLabel = ''
internalLinkUrl = 'verstak://secret/sec_example'
function handleInternalLinkPickerClose() {
showInternalLinkPicker = false;
}
// ===== Note rename =====
@ -1459,38 +1458,10 @@
}
function handleNoteInsertInternalLink() {
openInternalLinkModal()
openInternalLinkPicker()
}
// Insert markdown link at cursor position in the editor textarea.
function insertInternalLinkMarkdown() {
if (!noteEditor) { showInternalLinkModal = false; return }
const label = internalLinkLabel.trim() || internalLinkUrl
const href = internalLinkUrl.trim() || 'verstak://secret/sec_example'
const md = `[${label}](${href})`
// Try to insert at cursor via the hidden textarea used by MarkdownEditor.
// If textarea not available, append to content.
const ta = document.querySelector('.md-textarea')
if (ta) {
const start = ta.selectionStart
const end = ta.selectionEnd
const before = noteEditor.content.substring(0, start)
const after = noteEditor.content.substring(end)
noteEditor.content = before + md + after
noteEditor.dirty = true
// Restore cursor after inserted text
requestAnimationFrame(() => {
ta.focus()
ta.selectionStart = ta.selectionEnd = start + md.length
})
} else {
noteEditor.content = noteEditor.content + (noteEditor.content.endsWith('\n') ? '' : '\n') + md + '\n'
noteEditor.dirty = true
}
showInternalLinkModal = false
}
// ===== Worklog =====
// ===== Verstak link handler =====
function openWorklogModal(entry = null) {
editingWorklogEntry = entry
acceptingSuggestion = null
@ -2919,6 +2890,7 @@
</div>
</div>
<NoteEditorPanel
bind:this={noteEditorPanel}
content={noteEditor.content}
viewMode={noteViewMode}
placeholder={t('note.placeholder')}
@ -4698,25 +4670,13 @@
</div>
{/if}
<!-- Simple internal link modal (replaces broken ObjectPickerModal) -->
{#if showInternalLinkModal}
<div class="modal-overlay" role="dialog" aria-modal="true" aria-label={t('note.internalLink.title')} on:click|self={cancelInternalLink} on:keydown={(e) => e.key === 'Escape' && cancelInternalLink()}>
<div class="modal" style="width: 420px;">
<h3>{t('note.internalLink.title')}</h3>
<div class="form-group">
<label for="il-label">{t('note.internalLink.label')}</label>
<input id="il-label" type="text" bind:value={internalLinkLabel} placeholder={t('note.internalLink.labelPlaceholder')} on:keydown={(e) => e.key === 'Enter' && submitInternalLink()} />
</div>
<div class="form-group">
<label for="il-url">{t('note.internalLink.url')}</label>
<input id="il-url" type="text" bind:value={internalLinkUrl} placeholder={t('note.internalLink.urlPlaceholder')} on:keydown={(e) => e.key === 'Enter' && submitInternalLink()} />
</div>
<div class="modal-actions">
<button class="btn" on:click={cancelInternalLink}>{t('common.cancel')}</button>
<button class="btn btn-primary" on:click={submitInternalLink}>{t('common.save')}</button>
</div>
</div>
</div>
<!-- Internal link picker -->
{#if showInternalLinkPicker}
<InternalLinkPicker
visible={true}
on:insert={handleInternalLinkPickerInsert}
on:close={handleInternalLinkPickerClose}
/>
{/if}
<!-- Note rename modal (for rename from notes list) -->

View File

@ -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>

View File

@ -9,6 +9,23 @@
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;

View File

@ -9,6 +9,7 @@
export let placeholder = '';
const dispatch = createEventDispatcher();
let activeEditor = undefined; // bind:this ref for the visible MarkdownEditor
function setMode(mode) {
dispatch('mode-change', { mode });
@ -34,6 +35,13 @@
function handleVerstakLink(e) {
dispatch('verstak-link', e.detail);
}
// ─── Public API ──────────────────────────────────────────────────
export function insertText(text) {
if (activeEditor && typeof activeEditor.insertText === 'function') {
activeEditor.insertText(text);
}
}
</script>
<div class="note-editor-panel" class:mode-edit={viewMode === 'edit'} class:mode-preview={viewMode === 'preview'} class:mode-split={viewMode === 'split'}>
@ -54,6 +62,7 @@
<div class="panel-content">
{#if viewMode === 'edit'}
<MarkdownEditor
bind:this={activeEditor}
{content}
{placeholder}
viewMode="edit"
@ -70,6 +79,7 @@
<div class="split-pane">
<div class="split-editor">
<MarkdownEditor
bind:this={activeEditor}
{content}
{placeholder}
viewMode="split"