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:
parent
39d3b82199
commit
7521eea109
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue