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; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-6mFhgd0M.js"></script> <script type="module" crossorigin src="/assets/main-C5mYAmha.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bmXj_j_Z.css"> <link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -17,6 +17,7 @@
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
import { t } from './lib/i18n' import { t } from './lib/i18n'
import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte' import NoteEditorPanel from './lib/components/notes/NoteEditorPanel.svelte'
import InternalLinkPicker from './lib/components/notes/InternalLinkPicker.svelte'
// ===== Wails v2 API call helper ===== // ===== Wails v2 API call helper =====
function wailsCall(method, ...args) { function wailsCall(method, ...args) {
@ -77,14 +78,12 @@
let activeTab = 'overview' let activeTab = 'overview'
let notes = [] let notes = []
let noteEditor = null let noteEditor = null
let noteEditorPanel = undefined; // bind:this ref for NoteEditorPanel
let noteViewMode = 'edit' let noteViewMode = 'edit'
let showLinkModal = false let showLinkModal = false
let linkModalLabel = '' let linkModalLabel = ''
let linkModalUrl = '' let linkModalUrl = ''
// Simple internal link modal state (replaces broken ObjectPickerModal) let showInternalLinkPicker = false
let showInternalLinkModal = false
let internalLinkLabel = ''
let internalLinkUrl = 'verstak://secret/sec_example'
let showVerstakToast = false let showVerstakToast = false
let verstakToastMessage = '' let verstakToastMessage = ''
// Note rename state // Note rename state
@ -1355,21 +1354,21 @@
showLinkModal = false showLinkModal = false
} }
// ===== Simple internal link modal ===== // ===== Internal link picker =====
function openInternalLinkModal() { function openInternalLinkPicker() {
internalLinkLabel = '' showInternalLinkPicker = true;
internalLinkUrl = 'verstak://secret/sec_example'
showInternalLinkModal = true
} }
function submitInternalLink() { function handleInternalLinkPickerInsert(e) {
insertInternalLinkMarkdown() const md = e.detail.markdown;
if (noteEditorPanel && typeof noteEditorPanel.insertText === 'function') {
noteEditorPanel.insertText(md);
}
showInternalLinkPicker = false;
} }
function cancelInternalLink() { function handleInternalLinkPickerClose() {
showInternalLinkModal = false showInternalLinkPicker = false;
internalLinkLabel = ''
internalLinkUrl = 'verstak://secret/sec_example'
} }
// ===== Note rename ===== // ===== Note rename =====
@ -1459,38 +1458,10 @@
} }
function handleNoteInsertInternalLink() { function handleNoteInsertInternalLink() {
openInternalLinkModal() openInternalLinkPicker()
} }
// Insert markdown link at cursor position in the editor textarea. // ===== Verstak link handler =====
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 =====
function openWorklogModal(entry = null) { function openWorklogModal(entry = null) {
editingWorklogEntry = entry editingWorklogEntry = entry
acceptingSuggestion = null acceptingSuggestion = null
@ -2919,6 +2890,7 @@
</div> </div>
</div> </div>
<NoteEditorPanel <NoteEditorPanel
bind:this={noteEditorPanel}
content={noteEditor.content} content={noteEditor.content}
viewMode={noteViewMode} viewMode={noteViewMode}
placeholder={t('note.placeholder')} placeholder={t('note.placeholder')}
@ -4698,25 +4670,13 @@
</div> </div>
{/if} {/if}
<!-- Simple internal link modal (replaces broken ObjectPickerModal) --> <!-- Internal link picker -->
{#if showInternalLinkModal} {#if showInternalLinkPicker}
<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()}> <InternalLinkPicker
<div class="modal" style="width: 420px;"> visible={true}
<h3>{t('note.internalLink.title')}</h3> on:insert={handleInternalLinkPickerInsert}
<div class="form-group"> on:close={handleInternalLinkPickerClose}
<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>
{/if} {/if}
<!-- Note rename modal (for rename from notes list) --> <!-- 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(); const dispatch = createEventDispatcher();
let textareaRef; 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 ────────────────────────────────────────── // ─── Core insert helper ──────────────────────────────────────────
function insertAtCursor(before, after = '', placeholder = '') { function insertAtCursor(before, after = '', placeholder = '') {
const ta = textareaRef; const ta = textareaRef;

View File

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