feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s

- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet
  becomes the title (hashtags stay at the link), its children the content
  of a new note in the same folder with the source's shares (a foreign
  source owner gets write access); the branch becomes [[title]] through
  the operation log, so open editors follow.
- Merge: drop a note onto another in the sidebar or use "Mit anderer
  Notiz zusammenführen…" in the note header. The own source is appended
  as an h2 section with its bullets below and goes to the trash; the toast
  offers a complete undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:25:46 +01:00
1 parent 4c26eb2960
commit 4d1b37c956
15 files changed
+934 -9

No files matched your search

+7
View File
@@ -160,6 +160,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'callout',
'divider',
'bild',
'extract',
'auslagern',
'zusammenführen',
'audio',
'sprachnotiz',
'diktat',
@@ -202,6 +205,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">🖼️</span>
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">📤</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
@@ -17,6 +17,7 @@ import {
Check,
CloudOff,
AlertTriangle,
GitMerge,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
@@ -44,6 +45,8 @@ export interface DocumentEditorProps {
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -190,6 +193,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile,
onOpenTask,
onSaveStatusChange,
onRequestMerge,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -375,14 +379,39 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {});
}, [otherPeople.length]);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null);
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => {
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => {
setNotice(message);
setNoticeAction(action || null);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
setNoticeAction(null);
}, action ? 7000 : 3500);
}, []);
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]]
// and this editor receives that change through the normal sync
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
try {
await engine?.syncNow(); // the branch as typed so far
const created = await api.extractBranch(docId, blockId, engine?.clientId || '');
await engine?.syncNow();
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, {
label: 'Öffnen',
run: () => onOpenDocBlock?.(created.id),
});
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, showNotice]
);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
@@ -817,6 +846,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
>
<Users className="w-3.5 h-3.5" />
</button>
{onRequestMerge && doc.isOwner && !inTrash && (
<button
type="button"
onClick={() => onRequestMerge(doc)}
className="btn-icon"
title="Mit anderer Notiz zusammenführen…"
>
<GitMerge className="w-3.5 h-3.5" />
</button>
)}
</div>
);
})()}
@@ -969,6 +1008,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1056,6 +1096,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
onExtractBlock={canEdit ? handleExtractBlock : undefined}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
@@ -1084,7 +1125,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
{notice}
<span>{notice}</span>
{noticeAction && (
<button
type="button"
onClick={() => {
noticeAction.run();
setNotice(null);
setNoticeAction(null);
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{noticeAction.label}
</button>
)}
</div>
)}
</div>
@@ -0,0 +1,109 @@
import React, { useMemo, useState } from 'react';
import { GitMerge, Search } from 'lucide-react';
import { FormSheet } from '../../components/FormSheet';
import { api } from '../../lib/api';
import { Document, MergeResult } from '../../lib/types';
interface MergeDialogProps {
docs: Document[];
sourceId: string;
/** Known after a drop in the sidebar; otherwise the user picks it here */
targetId?: string | null;
onClose: () => void;
onMerged: (result: MergeResult) => void;
}
const canWrite = (d: Document) => d.isOwner || d.permission === 'owner' || d.permission === 'write';
// Merging a note into another: pick the target (when not dropped onto one), confirm, merge. The source
// is appended to the target under a heading with its title and goes to the trash.
export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, targetId, onClose, onMerged }) => {
const [target, setTarget] = useState<string | null>(targetId || null);
const [query, setQuery] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const source = docs.find((d) => d.id === sourceId);
const targetDoc = docs.find((d) => d.id === target);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return docs
.filter((d) => d.id !== sourceId && !d.isArchived && canWrite(d))
.filter((d) => !q || (d.title || '').toLowerCase().includes(q))
.slice(0, 50);
}, [docs, sourceId, query]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!target) return;
setBusy(true);
setError(null);
try {
onMerged(await api.mergeDocuments(target, sourceId));
} catch (err: any) {
setError(err?.message || 'Zusammenführen fehlgeschlagen');
setBusy(false);
}
};
const title = (d?: Document) => `„${d?.title || 'Unbenannte Notiz'}“`;
return (
<FormSheet
title="Notizen zusammenführen"
icon={<GitMerge className="w-4 h-4" />}
onClose={onClose}
onSubmit={submit}
submitLabel={target ? 'Zusammenführen' : 'Ziel wählen'}
busy={busy || !target}
>
{targetDoc ? (
<div className="space-y-2 text-xs">
<p className="text-main">
Notiz {title(source)} in {title(targetDoc)} zusammenführen?
</p>
<p className="text-muted">
Die Punkte von {title(source)} werden als eigener Abschnitt an {title(targetDoc)} angehängt.{' '}
{title(source)} wandert danach in den Papierkorb; „Rückgängig“ macht beides wieder rückgängig.
</p>
{!targetId && (
<button type="button" onClick={() => setTarget(null)} className="text-accent hover:underline min-h-[44px] sm:min-h-0">
Anderes Ziel wählen
</button>
)}
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted">In welche Notiz soll {title(source)} eingefügt werden?</p>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Notiz suchen…"
className="w-full h-11 sm:h-9 pl-8 pr-2 input-field text-sm"
/>
</div>
<ul className="max-h-64 overflow-y-auto divide-y divide-border rounded-card border border-border">
{candidates.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => setTarget(d.id)}
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
</button>
</li>
))}
{candidates.length === 0 && <li className="px-3 py-3 text-xs text-faint">Keine passende Notiz</li>}
</ul>
</div>
)}
{error && <p className="text-xs text-status-err">{error}</p>}
</FormSheet>
);
};
@@ -23,6 +23,7 @@ import {
Image as ImageIcon,
Mic,
AudioLines,
FileOutput,
} from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
@@ -41,7 +42,7 @@ import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand {
type: BlockType | 'link' | 'dictation';
type: BlockType | 'link' | 'dictation' | 'extract';
label: string;
desc: string;
icon: React.ReactNode;
@@ -60,6 +61,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
{ type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] },
{ type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] },
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] },
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, media: true, aliases: ['dictate'] },
@@ -76,6 +78,8 @@ export interface OutlinerBlockProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
/** Turns this bullet and its children into a note of its own */
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
@@ -123,6 +127,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
@@ -362,11 +367,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(c) =>
(!c.media || emptyBullet) &&
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
// The bullet's text becomes the title of the new note
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
(c.label.toLowerCase().includes(cmdQuery) ||
c.desc.toLowerCase().includes(cmdQuery) ||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
);
}, [slashSearch, localContent]);
}, [slashSearch, localContent, onExtractBlock]);
const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null);
@@ -377,6 +384,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
setShowLinkModal(true);
return;
}
if (cmd.type === 'extract') {
setLocalContent(clean);
localContentRef.current = clean;
flushDebounce(clean);
onExtractBlock?.(block.id);
return;
}
if (cmd.media) {
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
setLocalContent('');
@@ -1272,6 +1286,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Branch into its own note (desktop; phones use /extract) */}
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
<button
type="button"
onClick={() => onExtractBlock(block.id)}
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
>
<FileOutput className="w-3.5 h-3.5" />
</button>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
@@ -13,6 +13,7 @@ export interface OutlinerTreeProps {
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
onExtractBlock?: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
@@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
canEdit,
levelOffset = 0,
onZoomBlock,
onExtractBlock,
onUpdateBlock,
onUpdateBlocks,
onCreateBlock,
@@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onRedo={onRedo}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
onExtractBlock={onExtractBlock}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onPasteMultiLines={handlePasteMultiLines}
@@ -27,6 +27,7 @@ import {
import {
Document,
DocumentBlock,
MergeResult,
DocShare,
SharedFolder,
TagDetails,
@@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -87,6 +89,9 @@ interface FolderTreeItemProps {
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
docDropProps,
mergeHoverId,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
setToast({ text, actions });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
}, []);
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
@@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDraggingDocId(docId);
};
const requestMerge = (sourceId: string, targetId?: string) => {
const source = docs.find((d) => d.id === sourceId);
if (!source?.isOwner) {
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
return;
}
setMergeDialog({ sourceId, targetId });
};
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
},
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
setMergeHoverId(null);
setDragOverFolder(null);
requestMerge(sourceId, targetId);
},
});
const handleMerged = async (result: MergeResult) => {
setMergeDialog(null);
triggerHaptic(20);
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
setSelectedDocId(result.targetId);
setTargetBlockId(null);
setSubView('editor');
setMobileShowDetail(true);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
{
label: 'Rückgängig',
run: async () => {
try {
await api.undoMerge(result);
await loadWorkspaceData();
setSelectedDocId(result.sourceId);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast('Zusammenführen rückgängig gemacht');
} catch (err: any) {
showToast(err?.message || 'Rückgängig fehlgeschlagen');
}
},
},
]);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
@@ -1348,13 +1434,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -1398,6 +1487,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setSubView('editor');
setMobileShowDetail(true);
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
@@ -1406,13 +1497,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
@@ -1527,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
@@ -2251,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</FormSheet>
)}
{mergeDialog && (
<MergeDialog
docs={docs}
sourceId={mergeDialog.sourceId}
targetId={mergeDialog.targetId}
onClose={() => setMergeDialog(null)}
onMerged={(r) => void handleMerged(r)}
/>
)}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setToast(null);
a.run();
}}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
>
{a.label}
</button>
))}
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
+24
View File
@@ -36,6 +36,7 @@ import {
User,
UserInvite,
VoiceAdminStatus,
MergeResult,
WSMessage,
} from './types';
import { extractHashtags } from './hashtags';
@@ -708,6 +709,29 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
/** Turns a bullet and its children into a note of its own; the bullet becomes a [[link]]. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${docId}/extract`, {
method: 'POST',
body: JSON.stringify({ blockId, clientId }),
});
},
/** Appends the source note to the target; the source goes to the trash. */
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
method: 'POST',
body: JSON.stringify({ sourceId }),
});
},
async undoMerge(m: MergeResult): Promise<void> {
await request(`/api/v1/documents/${m.targetId}/merge/undo`, {
method: 'POST',
body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }),
});
},
async restoreDocument(id: string): Promise<void> {
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
},
+8
View File
@@ -564,3 +564,11 @@ export interface Session {
ip: string;
current: boolean;
}
// Answer of merging two notes; kept by the client to undo it
export interface MergeResult {
targetId: string;
sourceId: string;
sourceTitle: string;
headingId: string;
}