Dev #2

Merged
sandrodach merged 37 commits from dev into main 2026-10-25 12:14:23 +01:00
Showing only changes of commit d34178aa1a - Show all commits
+325 -22
View File
@@ -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>
);
};