feat(workspace): links to single bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s

- copy a link to a bullet from its action bubble or pick a bullet in the
  link dialog; [[Note#^id|Note › excerpt]] opens the note, jumps to and
  highlights the bullet, or says it is gone
- wiki links render inline with their label and are clickable; the
  duplicate chip below the text is gone

test(e2e): run each suite with its own container, image tag and port, so
parallel runs from another working copy no longer remove our server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 20:42:14 +01:00
1 parent d408e7d307
commit 1a49e6b304
12 files changed
+284 -45

No files matched your search

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { buildBlockLink } from '../../lib/wikiLink';
import {
Pin,
Users,
@@ -51,7 +52,7 @@ export interface DocumentEditorProps {
onRestoreDoc?: (docId: string) => void;
onOpenShareModal: (doc: Document) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
@@ -495,6 +496,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, actions.length ? 8000 : 3500);
}, []);
// Link to a single bullet: [[Titel#^id|Titel › Auszug]] into the clipboard
const handleCopyBlockLink = useCallback(
(blockId: string) => {
const block = blocks.find((b) => b.id === blockId);
const link = buildBlockLink(title || doc?.title || 'Notiz', blockId, block?.content || '');
const done = () => showNotice('Link zum Punkt kopiert – mit Strg+V in eine Notiz einfügen');
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(link).then(done, () => showNotice(`Kopieren nicht möglich: ${link}`));
} else {
showNotice(`Kopieren nicht möglich: ${link}`);
}
},
[blocks, title, doc?.title, showNotice]
);
// A link pointed to a bullet that is gone (moved, extracted, deleted): the note opens with a hint
const blocksForTargetRef = useRef(blocks);
blocksForTargetRef.current = blocks;
useEffect(() => {
if (!initialFocusedBlockId || !doc?.id) return;
const timer = window.setTimeout(() => {
if (!blocksForTargetRef.current.some((b) => b.id === initialFocusedBlockId)) {
showNotice('Der verlinkte Punkt ist in dieser Notiz nicht mehr vorhanden');
}
}, 1500);
return () => window.clearTimeout(timer);
}, [initialFocusedBlockId, doc?.id, showNotice]);
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
const finishUndoExtract = useCallback(() => {
@@ -1188,6 +1217,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={handleCopyBlockLink}
initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
@@ -1277,6 +1307,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={handleCopyBlockLink}
initialFocusedBlockId={initialFocusedBlockId}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
@@ -25,7 +25,7 @@ interface JournalViewProps {
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onOpenTask?: (taskId: string) => void;
onBackMobile: () => void;
+108 -9
View File
@@ -8,9 +8,12 @@ import {
ExternalLink,
Loader2,
Plus,
ArrowLeft,
ListTree,
} from 'lucide-react';
import { api } from '../../lib/api';
import { Document } from '../../lib/types';
import { Document, DocumentBlock } from '../../lib/types';
import { buildBlockLink } from '../../lib/wikiLink';
export interface LinkModalProps {
isOpen: boolean;
@@ -100,6 +103,37 @@ export const LinkModal: React.FC<LinkModalProps> = ({
);
}, [docs, search]);
// Picking a single bullet of a note (link to that bullet)
const [pickDoc, setPickDoc] = useState<Document | null>(null);
const [pickBlocks, setPickBlocks] = useState<DocumentBlock[] | null>(null);
const [blockSearch, setBlockSearch] = useState('');
useEffect(() => {
if (!isOpen) {
setPickDoc(null);
setPickBlocks(null);
setBlockSearch('');
}
}, [isOpen]);
const openBlockPicker = (doc: Document) => {
setPickDoc(doc);
setBlockSearch('');
if (doc.blocks && doc.blocks.length > 0) {
setPickBlocks(doc.blocks);
return;
}
setPickBlocks(null);
api
.getDocument(doc.id)
.then((full) => setPickBlocks(full.blocks || []))
.catch(() => setPickBlocks([]));
};
const pickableBlocks = useMemo(() => {
const q = blockSearch.trim().toLowerCase();
return (pickBlocks || []).filter(
(b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q))
);
}, [pickBlocks, blockSearch]);
const handleSelectNote = (doc: { title: string }) => {
const noteTitle = doc.title.trim() || 'Notiz';
onInsertLink(`[[${noteTitle}]]`);
@@ -244,8 +278,59 @@ export const LinkModal: React.FC<LinkModalProps> = ({
)}
</div>
{/* Bullets of one note: link to a single bullet */}
{tab === 'note' && pickDoc && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPickDoc(null)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 shrink-0"
title="Zurück zur Notizliste"
aria-label="Zurück zur Notizliste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<span className="text-xs font-semibold text-main truncate">
Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen
</span>
</div>
<input
type="text"
value={blockSearch}
onChange={(e) => setBlockSearch(e.target.value)}
placeholder="Punkte filtern…"
className="input-field w-full h-11 sm:h-8 px-2.5 text-xs"
aria-label="Punkte filtern"
/>
<div className="space-y-0.5 max-h-64 overflow-y-auto pr-0.5">
{pickBlocks === null ? (
<div className="p-4 text-xs text-muted flex items-center gap-2 justify-center">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Punkte werden geladen…</span>
</div>
) : pickableBlocks.length === 0 ? (
<div className="p-4 text-xs text-muted text-center">Keine passenden Punkte.</div>
) : (
pickableBlocks.map((b) => (
<button
key={b.id}
type="button"
onClick={() => onInsertLink(buildBlockLink(pickDoc.title || 'Notiz', b.id, b.content))}
className="w-full text-left min-h-[44px] sm:min-h-0 py-1.5 pr-2 rounded-btn text-xs text-main hover:bg-panel-elevated hover:text-accent flex items-start gap-1.5 cursor-pointer"
style={{ paddingLeft: `${0.5 + Math.min(b.level || 0, 5) * 0.875}rem` }}
>
<span className="text-faint shrink-0">•</span>
<span className="line-clamp-2 break-words">{b.content}</span>
</button>
))
)}
</div>
</div>
)}
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
{tab === 'note' && (
{tab === 'note' && !pickDoc && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
<span>Verfügbare Notizen</span>
@@ -289,13 +374,27 @@ export const LinkModal: React.FC<LinkModalProps> = ({
)}
</div>
</div>
<button
type="button"
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
>
<span>Einfügen</span>
<ExternalLink className="w-3 h-3" />
</button>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openBlockPicker(d as Document);
}}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 text-muted hover:text-accent"
title="Einen einzelnen Punkt dieser Notiz verlinken"
aria-label="Punkt dieser Notiz wählen"
>
<ListTree className="w-3.5 h-3.5" />
</button>
<button
type="button"
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer"
>
<span>Einfügen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
))
)}
@@ -18,13 +18,13 @@ import {
ExternalLink,
Maximize2,
Link as LinkIcon,
FileText,
Lock,
AlertTriangle,
Image as ImageIcon,
Mic,
AudioLines,
FileOutput,
Link2,
Type,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
@@ -94,7 +94,9 @@ export interface OutlinerBlockProps {
onBlurBlock?: (blockId: string) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
/** Copies a link to this bullet ([[Notiz#^id|…]]) */
onCopyBlockLink?: (blockId: string) => void;
onDragStartBlock?: (index: number) => void;
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
onDropBlock?: (index: number) => void;
@@ -144,6 +146,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onOpenTask,
onTagClick,
onWikiLinkClick,
onCopyBlockLink,
onDragStartBlock,
onDragOverBlock,
onDropBlock,
@@ -1065,9 +1068,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}, [localContent]);
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
// Wiki links are readable and clickable in the text itself, so only web links get a chip
const hasSpecialTokens = tokens.some(
(t) =>
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
(/^https?:\/\//.test(t) && t !== previewUrl)
);
@@ -1384,7 +1387,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
>
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
{localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'}
</span>
</div>
@@ -1504,21 +1507,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{hasSpecialTokens && !isFocused && (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{tokens.map((part, idx) => {
if (part.startsWith('[[') && part.endsWith(']]')) {
const page = part.slice(2, -2).trim();
return (
<button
key={idx}
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}`}
>
<FileText className="w-2.5 h-2.5 opacity-80" />
<span>{page}</span>
</button>
);
}
if (part.startsWith('[[')) return null;
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch && safeHref(mdMatch[2])) {
const label = mdMatch[1];
@@ -1727,6 +1716,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
)}
{/* Link to this bullet */}
{onCopyBlockLink && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onCopyBlockLink(block.id)}
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Link zu diesem Punkt kopieren"
aria-label="Link zu diesem Punkt kopieren"
>
<Link2 className="w-3.5 h-3.5" />
</button>
)}
{/* Branch into its own note */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
@@ -26,7 +26,8 @@ export interface OutlinerTreeProps {
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
onCopyBlockLink?: (blockId: string) => void;
initialFocusedBlockId?: string | null;
onFocusedBlockChange?: (blockId: string | null) => void;
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
@@ -53,6 +54,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onOpenTask,
onTagClick,
onWikiLinkClick,
onCopyBlockLink,
initialFocusedBlockId,
onFocusedBlockChange,
lockedBlocks,
@@ -640,6 +642,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={onCopyBlockLink}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
@@ -939,10 +939,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
const handleWikiLinkClick = useCallback(
(pageTitle: string) => {
(pageTitle: string, blockId?: string) => {
const clean = pageTitle.trim().toLowerCase();
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
if (match) {
if (match && blockId) {
handleJumpToDocBlock(match.id, blockId);
} else if (match) {
setSelectedDocId(match.id);
setSubView('editor');
setMobileShowDetail(true);
@@ -950,7 +952,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
handleCreateDocument('');
}
},
[docs, handleCreateDocument]
[docs, handleCreateDocument, handleJumpToDocBlock]
);
const openDocShareModal = useCallback(async (doc: Document) => {