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

@@ -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>