feat(workspace): links to single bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m8s
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:
1 parent
d408e7d307
commit
1a49e6b304
12 files changed
+284
-45
No files matched your search
@@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- 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.
|
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
||||||
|
|||||||
@@ -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).
|
* **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.
|
* **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).
|
* **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`.
|
* 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.
|
* 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.
|
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||||
|
|||||||
+26
-8
@@ -1,18 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# 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
|
set -euo pipefail
|
||||||
ENGINE="${ENGINE:-podman}"
|
ENGINE="${ENGINE:-podman}"
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
WORK="$(mktemp -d)"
|
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 …"
|
echo "Baue Image …"
|
||||||
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null
|
"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null
|
||||||
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \
|
"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \
|
||||||
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
-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:18080/api/v1/health >/dev/null && break; sleep 0.5; done
|
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 …"
|
echo "Starte Browser-Tests (Port $PORT) …"
|
||||||
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
"$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'
|
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'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { buildBlockLink } from '../../lib/wikiLink';
|
||||||
import {
|
import {
|
||||||
Pin,
|
Pin,
|
||||||
Users,
|
Users,
|
||||||
@@ -51,7 +52,7 @@ export interface DocumentEditorProps {
|
|||||||
onRestoreDoc?: (docId: string) => void;
|
onRestoreDoc?: (docId: string) => void;
|
||||||
onOpenShareModal: (doc: Document) => void;
|
onOpenShareModal: (doc: Document) => void;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
onBackMobile?: () => void;
|
onBackMobile?: () => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
@@ -495,6 +496,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, actions.length ? 8000 : 3500);
|
}, 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
|
// 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 lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
||||||
const finishUndoExtract = useCallback(() => {
|
const finishUndoExtract = useCallback(() => {
|
||||||
@@ -1188,6 +1217,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
lockedBlocks={lockedBlocks}
|
lockedBlocks={lockedBlocks}
|
||||||
@@ -1277,6 +1307,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
lockedBlocks={lockedBlocks}
|
lockedBlocks={lockedBlocks}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface JournalViewProps {
|
|||||||
onDocMetadataChange: (updated: Document) => void;
|
onDocMetadataChange: (updated: Document) => void;
|
||||||
onDeleteDoc: (docId: string) => void;
|
onDeleteDoc: (docId: string) => void;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onBackMobile: () => void;
|
onBackMobile: () => void;
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
ListTree,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { Document } from '../../lib/types';
|
import { Document, DocumentBlock } from '../../lib/types';
|
||||||
|
import { buildBlockLink } from '../../lib/wikiLink';
|
||||||
|
|
||||||
export interface LinkModalProps {
|
export interface LinkModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -100,6 +103,37 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
);
|
);
|
||||||
}, [docs, search]);
|
}, [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 handleSelectNote = (doc: { title: string }) => {
|
||||||
const noteTitle = doc.title.trim() || 'Notiz';
|
const noteTitle = doc.title.trim() || 'Notiz';
|
||||||
onInsertLink(`[[${noteTitle}]]`);
|
onInsertLink(`[[${noteTitle}]]`);
|
||||||
@@ -244,8 +278,59 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) */}
|
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
||||||
{tab === 'note' && (
|
{tab === 'note' && !pickDoc && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||||
<span>Verfügbare Notizen</span>
|
<span>Verfügbare Notizen</span>
|
||||||
@@ -289,13 +374,27 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
type="button"
|
<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"
|
type="button"
|
||||||
>
|
onClick={(e) => {
|
||||||
<span>Einfügen</span>
|
e.stopPropagation();
|
||||||
<ExternalLink className="w-3 h-3" />
|
openBlockPicker(d as Document);
|
||||||
</button>
|
}}
|
||||||
|
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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
FileText,
|
|
||||||
Lock,
|
Lock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Mic,
|
Mic,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
|
Link2,
|
||||||
Type,
|
Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
@@ -94,7 +94,9 @@ export interface OutlinerBlockProps {
|
|||||||
onBlurBlock?: (blockId: string) => void;
|
onBlurBlock?: (blockId: string) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: 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;
|
onDragStartBlock?: (index: number) => void;
|
||||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||||
onDropBlock?: (index: number) => void;
|
onDropBlock?: (index: number) => void;
|
||||||
@@ -144,6 +146,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onOpenTask,
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
|
onCopyBlockLink,
|
||||||
onDragStartBlock,
|
onDragStartBlock,
|
||||||
onDragOverBlock,
|
onDragOverBlock,
|
||||||
onDropBlock,
|
onDropBlock,
|
||||||
@@ -1065,9 +1068,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, [localContent]);
|
}, [localContent]);
|
||||||
|
|
||||||
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
|
// #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(
|
const hasSpecialTokens = tokens.some(
|
||||||
(t) =>
|
(t) =>
|
||||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
|
||||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||||
(/^https?:\/\//.test(t) && t !== previewUrl)
|
(/^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 */}
|
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
{localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1504,21 +1507,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
{hasSpecialTokens && !isFocused && (
|
{hasSpecialTokens && !isFocused && (
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||||
{tokens.map((part, idx) => {
|
{tokens.map((part, idx) => {
|
||||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
if (part.startsWith('[[')) return null;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||||
if (mdMatch && safeHref(mdMatch[2])) {
|
if (mdMatch && safeHref(mdMatch[2])) {
|
||||||
const label = mdMatch[1];
|
const label = mdMatch[1];
|
||||||
@@ -1727,6 +1716,19 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Branch into its own note */}
|
||||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export interface OutlinerTreeProps {
|
|||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
|
onCopyBlockLink?: (blockId: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||||
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
||||||
@@ -53,6 +54,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onOpenTask,
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
|
onCopyBlockLink,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
onFocusedBlockChange,
|
onFocusedBlockChange,
|
||||||
lockedBlocks,
|
lockedBlocks,
|
||||||
@@ -640,6 +642,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={onCopyBlockLink}
|
||||||
onDragStartBlock={handleDragStartBlock}
|
onDragStartBlock={handleDragStartBlock}
|
||||||
onDragOverBlock={handleDragOverBlock}
|
onDragOverBlock={handleDragOverBlock}
|
||||||
onDropBlock={handleDropBlock}
|
onDropBlock={handleDropBlock}
|
||||||
|
|||||||
@@ -939,10 +939,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWikiLinkClick = useCallback(
|
const handleWikiLinkClick = useCallback(
|
||||||
(pageTitle: string) => {
|
(pageTitle: string, blockId?: string) => {
|
||||||
const clean = pageTitle.trim().toLowerCase();
|
const clean = pageTitle.trim().toLowerCase();
|
||||||
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
|
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);
|
setSelectedDocId(match.id);
|
||||||
setSubView('editor');
|
setSubView('editor');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
@@ -950,7 +952,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
handleCreateDocument('');
|
handleCreateDocument('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[docs, handleCreateDocument]
|
[docs, handleCreateDocument, handleJumpToDocBlock]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDocShareModal = useCallback(async (doc: Document) => {
|
const openDocShareModal = useCallback(async (doc: Document) => {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||||
|
|
||||||
|
describe('wiki links', () => {
|
||||||
|
it('parses plain, labelled, section and bullet links', () => {
|
||||||
|
expect(parseWikiLink('Homelab')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||||
|
expect(parseWikiLink('Homelab|hier')).toEqual({ title: 'Homelab', blockId: undefined, label: 'hier' });
|
||||||
|
expect(parseWikiLink('Homelab#Abschnitt')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||||
|
expect(parseWikiLink('Homelab#^blk_a_1|Homelab › Backup')).toEqual({
|
||||||
|
title: 'Homelab', blockId: 'blk_a_1', label: 'Homelab › Backup',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a bullet link that parses back', () => {
|
||||||
|
const link = buildBlockLink('Homelab [neu]', 'blk_x', 'Backups liefen bisher nur manuell – das soll sich ändern #backup');
|
||||||
|
expect(link).toBe('[[Homelab neu#^blk_x|Homelab neu › Backups liefen bisher nur manuell – das…]]');
|
||||||
|
const parsed = parseWikiLink(link.slice(2, -2));
|
||||||
|
expect(parsed.blockId).toBe('blk_x');
|
||||||
|
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { safeHref } from './safeUrl';
|
import { safeHref } from './safeUrl';
|
||||||
|
import { parseWikiLink, wikiLinkText } from './wikiLink';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses inline markdown:
|
* Parses inline markdown:
|
||||||
@@ -9,9 +10,11 @@ import { safeHref } from './safeUrl';
|
|||||||
* - `code`
|
* - `code`
|
||||||
* - [label](url)
|
* - [label](url)
|
||||||
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||||
|
* - [[wiki link]] shown by its label (clickable with `options.onWikiLinkClick`)
|
||||||
*/
|
*/
|
||||||
export interface InlineMarkdownOptions {
|
export interface InlineMarkdownOptions {
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (title: string, blockId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||||
@@ -19,7 +22,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
|
|
||||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||||
const regex = new RegExp(
|
const regex = new RegExp(
|
||||||
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
'(\\[\\[[^\\]]+\\]\\]|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||||
'g'
|
'g'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,7 +37,31 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
const token = match[0];
|
const token = match[0];
|
||||||
const key = `${match.index}-${token}`;
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
if (token.startsWith('#')) {
|
if (token.startsWith('[[') && token.endsWith(']]')) {
|
||||||
|
const link = parseWikiLink(token.slice(2, -2));
|
||||||
|
const onClick = options?.onWikiLinkClick;
|
||||||
|
elements.push(
|
||||||
|
onClick ? (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(link.title, link.blockId);
|
||||||
|
}}
|
||||||
|
className="text-status-info underline decoration-status-info/40 underline-offset-2 hover:decoration-status-info cursor-pointer"
|
||||||
|
title={link.blockId ? `Zum Punkt in „${link.title}“` : `Notiz öffnen: ${link.title}`}
|
||||||
|
>
|
||||||
|
{wikiLinkText(link)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={key} className="text-status-info">
|
||||||
|
{wikiLinkText(link)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('#')) {
|
||||||
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||||
if (prevChar && !/[\s(]/.test(prevChar)) {
|
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||||
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Wiki links: [[Titel]], [[Titel|Anzeigetext]] and links to a single bullet [[Titel#^blockId|Anzeigetext]].
|
||||||
|
// A missing bullet just opens the note (links are not updated when a bullet moves to another note).
|
||||||
|
|
||||||
|
export interface WikiLink {
|
||||||
|
title: string;
|
||||||
|
blockId?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the inside of [[…]]. */
|
||||||
|
export function parseWikiLink(inner: string): WikiLink {
|
||||||
|
const [target, ...rest] = inner.split('|');
|
||||||
|
const label = rest.join('|').trim() || undefined;
|
||||||
|
const hash = target.indexOf('#');
|
||||||
|
const title = (hash === -1 ? target : target.slice(0, hash)).trim();
|
||||||
|
const anchor = hash === -1 ? '' : target.slice(hash + 1).trim();
|
||||||
|
return { title, blockId: anchor.startsWith('^') ? anchor.slice(1) || undefined : undefined, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text shown for a link: its label, else the note title (with » for a bullet). */
|
||||||
|
export function wikiLinkText(link: WikiLink): string {
|
||||||
|
if (link.label) return link.label;
|
||||||
|
return link.blockId ? `${link.title} »` : link.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link to one bullet; the label is a short excerpt of the bullet so the reader knows what it points to. */
|
||||||
|
export function buildBlockLink(title: string, blockId: string, blockContent: string): string {
|
||||||
|
const clean = (s: string) => s.replace(/[[\]|]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
let excerpt = clean(blockContent);
|
||||||
|
if (excerpt.length > 40) excerpt = `${excerpt.slice(0, 39).trimEnd()}…`;
|
||||||
|
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
|
||||||
|
return `[[${clean(title)}#^${blockId}|${label}]]`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user