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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
+24
-64
@@ -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) -->
|
||||
|
||||
@@ -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();
|
||||
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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user