feat(workspace): add /link slash command with note selector and web link modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
This commit is contained in:
1 parent
1bcf6b4f18
commit
06f5f7043c
2 files changed
+345
-5
No files matched your search
@@ -0,0 +1,271 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Link as LinkIcon, FileText, Globe, X, Search, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { Document } from '../../lib/types';
|
||||
|
||||
export interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInsertLink: (markdown: string) => void;
|
||||
availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[];
|
||||
}
|
||||
|
||||
export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onInsertLink,
|
||||
availableDocs,
|
||||
}) => {
|
||||
const [tab, setTab] = useState<'note' | 'web'>('note');
|
||||
const [search, setSearch] = useState('');
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [webTitle, setWebTitle] = useState('');
|
||||
const [webUrl, setWebUrl] = useState('');
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const webTitleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Load documents when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (availableDocs && availableDocs.length > 0) {
|
||||
setDocs(availableDocs as Document[]);
|
||||
return;
|
||||
}
|
||||
let isCancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.getDocuments()
|
||||
.then((fetched) => {
|
||||
if (!isCancelled) {
|
||||
setDocs(fetched);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [isOpen, availableDocs]);
|
||||
|
||||
// Focus input on mount or tab change
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (tab === 'note') {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
webTitleInputRef.current?.focus();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, tab]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
if (!search.trim()) return docs;
|
||||
const q = search.toLowerCase();
|
||||
return docs.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
(d.folderPath && d.folderPath.toLowerCase().includes(q))
|
||||
);
|
||||
}, [docs, search]);
|
||||
|
||||
const handleSelectNote = (doc: { title: string }) => {
|
||||
const noteTitle = doc.title.trim() || 'Notiz';
|
||||
onInsertLink(`[[${noteTitle}]]`);
|
||||
};
|
||||
|
||||
const handleWebLinkSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let url = webUrl.trim();
|
||||
if (!url) return;
|
||||
if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
const label = webTitle.trim() || url;
|
||||
onInsertLink(`[${label}](${url})`);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4 backdrop-blur-xs animate-in fade-in duration-100"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="panel-surface shadow-panel border border-border rounded-card max-w-md w-full overflow-hidden flex flex-col max-h-[85vh]">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="w-4 h-4 text-accent" />
|
||||
<h3 className="text-sm font-bold text-main">Link einfügen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-btn text-muted hover:text-main hover:bg-panel-elevated transition-colors"
|
||||
title="Schließen (Esc)"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('note')}
|
||||
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
|
||||
tab === 'note'
|
||||
? 'bg-accent text-on-accent shadow-sm'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Notiz verknüpfen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('web')}
|
||||
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
|
||||
tab === 'web'
|
||||
? 'bg-accent text-on-accent shadow-sm'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Web-Link</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
{tab === 'note' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Notiz suchen…"
|
||||
className="w-full bg-input border border-border focus:border-accent text-main text-xs pl-8 pr-3 py-1.5 rounded-btn focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-8 flex items-center justify-center text-xs text-muted gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span>Notizen werden geladen…</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 pr-1">
|
||||
{filteredDocs.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectNote(d)}
|
||||
className="w-full text-left px-3 py-2 rounded-btn hover:bg-panel-elevated flex items-center justify-between text-xs transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base shrink-0">{d.icon || '📝'}</span>
|
||||
<span className="font-medium text-main truncate group-hover:text-accent">
|
||||
{d.title || 'Unbenannte Notiz'}
|
||||
</span>
|
||||
</div>
|
||||
{d.folderPath && (
|
||||
<span className="text-[10px] text-faint ml-2 shrink-0 truncate max-w-[100px]">
|
||||
📁 {d.folderPath}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredDocs.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-muted">
|
||||
{search
|
||||
? `Keine Notiz für "${search}" gefunden.`
|
||||
: 'Keine Notizen im Workspace vorhanden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleWebLinkSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted mb-1">
|
||||
Link-Text / Titel
|
||||
</label>
|
||||
<input
|
||||
ref={webTitleInputRef}
|
||||
type="text"
|
||||
value={webTitle}
|
||||
onChange={(e) => setWebTitle(e.target.value)}
|
||||
placeholder="z. B. MindSift Dokumentation"
|
||||
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-muted mb-1">
|
||||
Web-Adresse (URL) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={webUrl}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action px-3 py-1.5 text-xs text-muted hover:text-main"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!webUrl.trim()}
|
||||
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-accent rounded-btn disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>Link einfügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
Hash,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { LinkModal } from './LinkModal';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType;
|
||||
type: BlockType | 'link';
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -28,6 +31,7 @@ export interface SlashCommand {
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||
@@ -89,6 +93,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
@@ -158,6 +163,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const applySlashCommand = (cmd: SlashCommand) => {
|
||||
setSlashSearch(null);
|
||||
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
|
||||
if (cmd.type === 'link') {
|
||||
setLocalContent(clean);
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
setLocalContent(clean);
|
||||
flushDebounce(clean, {
|
||||
type: cmd.type,
|
||||
@@ -165,6 +175,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsertLink = (linkMarkdown: string) => {
|
||||
setShowLinkModal(false);
|
||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
||||
const before = localContent.slice(0, cursor);
|
||||
const after = localContent.slice(cursor);
|
||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
||||
setLocalContent(newContent);
|
||||
flushDebounce(newContent);
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
@@ -339,14 +359,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const level = Math.max(0, block.level || 0);
|
||||
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
||||
|
||||
// Parse rich tokens (WikiLinks [[Page]] and #Tags)
|
||||
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
||||
const tokens = useMemo(() => {
|
||||
const parts = (localContent || '').split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
const parts = (localContent || '').split(
|
||||
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
|
||||
);
|
||||
return parts.filter(Boolean);
|
||||
}, [localContent]);
|
||||
|
||||
const hasSpecialTokens = tokens.some(
|
||||
(t) => t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]'))
|
||||
(t) =>
|
||||
t.startsWith('#') ||
|
||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||
/^https?:\/\//.test(t)
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -619,12 +645,46 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
type="button"
|
||||
onClick={() => onWikiLinkClick?.(page)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
|
||||
title={`Notiz öffnen: ${page}`}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<FileText className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{page}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch) {
|
||||
const label = mdMatch[1];
|
||||
const url = mdMatch[2];
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={url}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{label || url}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={part}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span className="truncate max-w-[160px]">{part}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
@@ -689,6 +749,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Link Insertion Modal */}
|
||||
{showLinkModal && (
|
||||
<LinkModal
|
||||
isOpen={showLinkModal}
|
||||
onClose={() => setShowLinkModal(false)}
|
||||
onInsertLink={handleInsertLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user