feat(workspace): add slash command link menu for internal notes and web links
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
1310f8102e
commit
d34178aa1a
1 file changed
+325
-22
@@ -26,6 +26,7 @@ import {
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Link,
|
||||
FileText,
|
||||
Table as TableIcon,
|
||||
Percent,
|
||||
Minus,
|
||||
@@ -68,13 +69,17 @@ interface FolderTreeItem {
|
||||
}
|
||||
|
||||
interface SlashCommandItem {
|
||||
type: BlockType;
|
||||
type?: BlockType;
|
||||
action?: 'insert_link' | 'insert_note' | 'insert_weblink';
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommandItem[] = [
|
||||
{ action: 'insert_link', label: '/link', desc: 'Webseite oder Notiz verlinken', icon: '🔗' },
|
||||
{ action: 'insert_note', label: '/notiz', desc: 'Notiz verlinken [[Notiz]]', icon: '📄' },
|
||||
{ action: 'insert_weblink', label: '/web', desc: 'Externe Webseite verlinken [Name](url)', icon: '🌐' },
|
||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' },
|
||||
@@ -135,6 +140,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||
const slashMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Link Insertion Modal state (/link, /notiz, /web)
|
||||
const [linkModalBlockId, setLinkModalBlockId] = useState<string | null>(null);
|
||||
const [linkModalTab, setLinkModalTab] = useState<'note' | 'web'>('note');
|
||||
const [linkSearchQuery, setLinkSearchQuery] = useState('');
|
||||
const [linkWebTitle, setLinkWebTitle] = useState('');
|
||||
const [linkWebUrl, setLinkWebUrl] = useState('');
|
||||
|
||||
// Drag and Drop reordering state for blocks
|
||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||
|
||||
@@ -1154,7 +1166,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
e.preventDefault();
|
||||
const selected = filteredSlashCommands[slashSelectedIndex];
|
||||
if (selected) {
|
||||
applySlashCommand(block.id, selected.type);
|
||||
applySlashCommand(block.id, selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1236,17 +1248,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
}, [slashSelectedIndex, slashBlockId]);
|
||||
|
||||
const applySlashCommand = (blockId: string, type: BlockType) => {
|
||||
if (isReadOnly || !selectedDoc) return;
|
||||
const block = selectedDoc.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return;
|
||||
const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim();
|
||||
updateBlock(blockId, { type, content: cleanContent, checked: type === 'todo' ? false : undefined });
|
||||
setSlashBlockId(null);
|
||||
setSlashSearch('');
|
||||
|
||||
// Re-focus immediately into the block's text field and position cursor at end
|
||||
const focusBlock = () => {
|
||||
const focusBlock = (blockId: string) => {
|
||||
setLastFocusedBlockId(blockId);
|
||||
const tryFocus = () => {
|
||||
const el = blockInputRefs.current[blockId];
|
||||
if (el) {
|
||||
el.focus();
|
||||
@@ -1259,14 +1263,79 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
setLastFocusedBlockId(blockId);
|
||||
setTimeout(() => {
|
||||
if (!focusBlock()) {
|
||||
setTimeout(focusBlock, 30);
|
||||
if (!tryFocus()) {
|
||||
setTimeout(tryFocus, 30);
|
||||
}
|
||||
}, 20);
|
||||
};
|
||||
|
||||
const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => {
|
||||
if (isReadOnly || !selectedDoc) return;
|
||||
const block = selectedDoc.blocks.find((b) => b.id === blockId);
|
||||
if (!block) return;
|
||||
const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim();
|
||||
|
||||
setSlashBlockId(null);
|
||||
setSlashSearch('');
|
||||
|
||||
if (cmd.action) {
|
||||
updateBlock(blockId, { content: cleanContent });
|
||||
setLinkModalBlockId(blockId);
|
||||
setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note');
|
||||
setLinkSearchQuery('');
|
||||
setLinkWebTitle('');
|
||||
setLinkWebUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type) {
|
||||
updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined });
|
||||
focusBlock(blockId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInsertNoteLink = (docTitle: string) => {
|
||||
if (!linkModalBlockId || !selectedDoc) return;
|
||||
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
|
||||
if (block) {
|
||||
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
|
||||
const newContent = `${block.content}${space}[[${docTitle}]] `;
|
||||
updateBlock(linkModalBlockId, { content: newContent });
|
||||
}
|
||||
const targetBlockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
focusBlock(targetBlockId);
|
||||
};
|
||||
|
||||
const handleInsertWebLink = () => {
|
||||
if (!linkModalBlockId || !selectedDoc) return;
|
||||
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
|
||||
let title = linkWebTitle.trim();
|
||||
let url = linkWebUrl.trim();
|
||||
if (!url) return;
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
if (!title) {
|
||||
try {
|
||||
title = new URL(url).hostname;
|
||||
} catch {
|
||||
title = url;
|
||||
}
|
||||
}
|
||||
if (block) {
|
||||
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
|
||||
const newContent = `${block.content}${space}[${title}](${url}) `;
|
||||
updateBlock(linkModalBlockId, { content: newContent });
|
||||
}
|
||||
const targetBlockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
setLinkWebTitle('');
|
||||
setLinkWebUrl('');
|
||||
focusBlock(targetBlockId);
|
||||
};
|
||||
|
||||
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
|
||||
const parent = blocks[parentIndex];
|
||||
if (!parent) return [];
|
||||
@@ -1423,9 +1492,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Helper to render text with clickable Wiki-Links and Hashtags
|
||||
// Helper to render text with clickable Wiki-Links, Web-Links and Hashtags
|
||||
const renderFormattedText = (text: string) => {
|
||||
const parts = text.split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const linkTitle = part.slice(2, -2).trim();
|
||||
@@ -1454,6 +1523,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
|
||||
if (webMatch) {
|
||||
const title = webMatch[1] || webMatch[2];
|
||||
const url = webMatch[2];
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors"
|
||||
title={`Öffne: ${url}`}
|
||||
>
|
||||
<span>🌐</span>
|
||||
<span>{title}</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('#') && part.length > 1) {
|
||||
const tagWord = part.slice(1);
|
||||
return (
|
||||
@@ -2548,7 +2637,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
|
||||
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5 pl-6">
|
||||
{renderFormattedText(block.content)}
|
||||
</div>
|
||||
@@ -2754,7 +2843,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
isReadOnly ? 'cursor-default' : ''
|
||||
}`}
|
||||
/>
|
||||
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
|
||||
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{renderFormattedText(block.content)}
|
||||
</div>
|
||||
@@ -2771,11 +2860,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div ref={slashMenuRef} className="relative max-h-48 overflow-y-auto space-y-0.5">
|
||||
{filteredSlashCommands.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.type}
|
||||
key={cmd.label || idx}
|
||||
type="button"
|
||||
data-slash-index={idx}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => applySlashCommand(block.id, cmd.type)}
|
||||
onClick={() => applySlashCommand(block.id, cmd)}
|
||||
className={`w-full text-left px-2 py-1.5 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||
slashSelectedIndex === idx
|
||||
? 'bg-accent text-on-accent'
|
||||
@@ -3436,6 +3525,220 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
||||
{linkModalBlockId && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={() => {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-panel border border-border rounded-xl shadow-panel w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-btn bg-accent/15 text-accent">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-main">Link einfügen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
}}
|
||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher: Notiz vs Webseite */}
|
||||
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLinkModalTab('note')}
|
||||
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
|
||||
linkModalTab === 'note'
|
||||
? 'bg-panel text-main shadow-xs font-semibold'
|
||||
: 'text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Notiz verlinken</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLinkModalTab('web')}
|
||||
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
|
||||
linkModalTab === 'web'
|
||||
? 'bg-panel text-main shadow-xs font-semibold'
|
||||
: 'text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>Webseite verlinken</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-4">
|
||||
{linkModalTab === 'note' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-2.5 text-muted pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Notiz nach Titel oder Ordner filtern..."
|
||||
value={linkSearchQuery}
|
||||
onChange={(e) => setLinkSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
} else if (e.key === 'Enter') {
|
||||
const filtered = docs.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
|
||||
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
handleInsertNoteLink(filtered[0].title);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="input-field w-full pl-9 h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 divide-y divide-border/30">
|
||||
{docs
|
||||
.filter(
|
||||
(d) =>
|
||||
!linkSearchQuery.trim() ||
|
||||
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
|
||||
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => handleInsertNoteLink(d.title)}
|
||||
className="w-full text-left px-2.5 py-2 rounded-btn text-xs hover:bg-panel-elevated flex items-center justify-between gap-2 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base flex-shrink-0">{d.icon || '📄'}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-main truncate group-hover:text-accent">
|
||||
{d.title}
|
||||
</div>
|
||||
{d.folderPath && (
|
||||
<div className="text-[10px] text-faint truncate">
|
||||
{d.folderPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-faint group-hover:text-accent flex-shrink-0 font-mono">
|
||||
[[{d.title}]]
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{docs.filter(
|
||||
(d) =>
|
||||
!linkSearchQuery.trim() ||
|
||||
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="py-6 text-center text-xs text-faint">
|
||||
Keine passende Notiz gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleInsertWebLink();
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted">Titel / Name der Verlinkung</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="z.B. Wikipedia oder Referenzseite"
|
||||
value={linkWebTitle}
|
||||
onChange={(e) => setLinkWebTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
}
|
||||
}}
|
||||
className="input-field w-full h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium text-muted">Web-Adresse (URL) *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="https://example.com"
|
||||
value={linkWebUrl}
|
||||
onChange={(e) => setLinkWebUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
}
|
||||
}}
|
||||
className="input-field w-full h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const blockId = linkModalBlockId;
|
||||
setLinkModalBlockId(null);
|
||||
if (blockId) focusBlock(blockId);
|
||||
}}
|
||||
className="btn-action h-8 px-3 text-xs cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!linkWebUrl.trim()}
|
||||
className="btn-primary h-8 px-4 text-xs font-medium cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
Link einfügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user