diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e10368..77f7efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar. + ### Changed - Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste. - Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile. diff --git a/README.md b/README.md index 629ac9c..80c084c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription). * **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse. * **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion). -* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. +* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. * Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. * **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. diff --git a/e2e/run.sh b/e2e/run.sh index 5152375..7a3fbb6 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1,18 +1,36 @@ #!/usr/bin/env bash -# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests. +# Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests. # Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh +# Every run uses its own container, image tag and port, so runs from several working copies at the +# same time cannot remove or test each other's server. set -euo pipefail ENGINE="${ENGINE:-podman}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORK="$(mktemp -d)" -trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT +RUN_ID="$$-$RANDOM" +NAME="mindsift-e2e-$RUN_ID" +IMAGE="mindsift-e2e-image:$RUN_ID" +# A free port above 18000 (E2E_PORT overrides) +PORT="${E2E_PORT:-}" +if [ -z "$PORT" ]; then + for p in $(shuf -i 18100-18999 -n 50); do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi + done +fi +cleanup() { + "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true + "$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT echo "Baue Image …" -"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null -"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \ - -e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null -for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done +"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null +"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \ + -e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null +for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done -echo "Starte Browser-Tests …" -"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ +echo "Starte Browser-Tests (Port $PORT) …" +"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -v "$ROOT/e2e":/e2e:Z -w /e2e \ + mcr.microsoft.com/playwright:v1.48.2-jammy \ sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs' diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 0d57749..d416af6 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ }, 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 = ({ 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 = ({ onOpenTask={onOpenTask} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} + onCopyBlockLink={handleCopyBlockLink} initialFocusedBlockId={initialFocusedBlockId} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} lockedBlocks={lockedBlocks} diff --git a/frontend/src/features/workspace/JournalView.tsx b/frontend/src/features/workspace/JournalView.tsx index 83bbd62..174bac7 100644 --- a/frontend/src/features/workspace/JournalView.tsx +++ b/frontend/src/features/workspace/JournalView.tsx @@ -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; diff --git a/frontend/src/features/workspace/LinkModal.tsx b/frontend/src/features/workspace/LinkModal.tsx index 2f31dcf..c0f6c5b 100644 --- a/frontend/src/features/workspace/LinkModal.tsx +++ b/frontend/src/features/workspace/LinkModal.tsx @@ -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 = ({ ); }, [docs, search]); + // Picking a single bullet of a note (link to that bullet) + const [pickDoc, setPickDoc] = useState(null); + const [pickBlocks, setPickBlocks] = useState(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 = ({ )} + {/* Bullets of one note: link to a single bullet */} + {tab === 'note' && pickDoc && ( +
+
+ + + Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen + +
+ 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" + /> +
+ {pickBlocks === null ? ( +
+ + Punkte werden geladen… +
+ ) : pickableBlocks.length === 0 ? ( +
Keine passenden Punkte.
+ ) : ( + pickableBlocks.map((b) => ( + + )) + )} +
+
+ )} + {/* Note List Section (Matching Active Shares list in UnifiedShareModal) */} - {tab === 'note' && ( + {tab === 'note' && !pickDoc && (
Verfügbare Notizen @@ -289,13 +374,27 @@ export const LinkModal: React.FC = ({ )}
- +
+ + +
)) )} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 699d99b..d17d65b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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 = ({ onOpenTask, onTagClick, onWikiLinkClick, + onCopyBlockLink, onDragStartBlock, onDragOverBlock, onDropBlock, @@ -1065,9 +1068,9 @@ export const OutlinerBlock: React.FC = ({ }, [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 = ({ > {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} - {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'} + {localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'} @@ -1504,21 +1507,7 @@ export const OutlinerBlock: React.FC = ({ {hasSpecialTokens && !isFocused && (
{tokens.map((part, idx) => { - if (part.startsWith('[[') && part.endsWith(']]')) { - const page = part.slice(2, -2).trim(); - return ( - - ); - } + 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 = ({
)} + {/* Link to this bullet */} + {onCopyBlockLink && localContent.trim() !== '' && !mediaPicker && ( + + )} + {/* Branch into its own note */} {canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (