feat: link dialog finds bullets of all notes; richer sample data with help notes, deep nesting and linked to-dos
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m46s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae96aa5f2b
commit
ca56220e68
12 files changed
+303
-40
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.46.0",
|
||||
"version": "0.47.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { SearchHit } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { SnippetRenderer } from './SnippetRenderer';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -19,32 +20,6 @@ interface CommandPaletteProps {
|
||||
onOpenList: (listId: string) => void;
|
||||
}
|
||||
|
||||
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!snippet) return [];
|
||||
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||
const text = part.slice(6, -7);
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||
>
|
||||
{text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
isOpen,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||
export const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!snippet) return [];
|
||||
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||
}, [snippet]);
|
||||
|
||||
return (
|
||||
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||
{parts.map((part, i) => {
|
||||
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||
const text = part.slice(6, -7);
|
||||
return (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||
>
|
||||
{text}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { api } from '../../lib/api';
|
||||
import { Document, DocumentBlock } from '../../lib/types';
|
||||
import { buildBlockLink } from '../../lib/wikiLink';
|
||||
import { SnippetRenderer } from '../../components/SnippetRenderer';
|
||||
|
||||
export interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -134,6 +135,50 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
);
|
||||
}, [pickBlocks, blockSearch]);
|
||||
|
||||
// Bullets of all notes matching the search (full-text index, like the global search), from 3 letters
|
||||
type BlockHit = { docId: string; blockId: string; title: string; icon?: string; snippet: string; content: string };
|
||||
const [blockHits, setBlockHits] = useState<BlockHit[]>([]);
|
||||
const [blockHitsLoading, setBlockHitsLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
const q = search.trim();
|
||||
if (!isOpen || tab !== 'note' || pickDoc || q.length < 3) {
|
||||
setBlockHits([]);
|
||||
setBlockHitsLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setBlockHitsLoading(true);
|
||||
const timer = window.setTimeout(() => {
|
||||
api
|
||||
.searchAll(q)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const docsById = new Map((res.documents || []).map((d) => [d.id, d]));
|
||||
const hits = (res.hits || [])
|
||||
.filter((h) => h.type === 'document' && h.blockId && h.docId)
|
||||
.slice(0, 8)
|
||||
.map((h) => {
|
||||
const block = docsById.get(h.docId!)?.blocks?.find((b) => b.id === h.blockId);
|
||||
return {
|
||||
docId: h.docId!,
|
||||
blockId: h.blockId!,
|
||||
title: h.title,
|
||||
icon: h.icon,
|
||||
snippet: h.snippet,
|
||||
content: block?.content ?? h.snippet.replace(/<\/?mark>/g, ''),
|
||||
};
|
||||
});
|
||||
setBlockHits(hits);
|
||||
})
|
||||
.catch(() => !cancelled && setBlockHits([]))
|
||||
.finally(() => !cancelled && setBlockHitsLoading(false));
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [search, isOpen, tab, pickDoc]);
|
||||
|
||||
const handleSelectNote = (doc: { title: string }) => {
|
||||
const noteTitle = doc.title.trim() || 'Notiz';
|
||||
onInsertLink(`[[${noteTitle}]]`);
|
||||
@@ -399,6 +444,33 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Matching bullets of all notes: a click links that bullet */}
|
||||
{search.trim().length >= 3 && (blockHitsLoading || blockHits.length > 0) && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5 pt-1">
|
||||
<span>Punkte</span>
|
||||
{blockHitsLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{blockHits.map((h) => (
|
||||
<button
|
||||
key={`${h.docId}:${h.blockId}`}
|
||||
type="button"
|
||||
onClick={() => onInsertLink(buildBlockLink(h.title || 'Notiz', h.blockId, h.content))}
|
||||
className="w-full text-left p-2.5 min-h-11 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors cursor-pointer space-y-0.5"
|
||||
title="Diesen Punkt verlinken"
|
||||
>
|
||||
<SnippetRenderer snippet={h.snippet} />
|
||||
<span className="block text-[10px] text-faint truncate">
|
||||
{h.icon ? `${h.icon} ` : ''}
|
||||
{h.title || 'Unbenannte Notiz'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user