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:
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
+2 -2
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>
+24 -64
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) -->
@@ -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>
@@ -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;
@@ -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"