fix(workspace): extract replaces the branch in the editor, with undo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m2s

The server only creates the new note and returns the link; the editor
swaps the branch for it as an ordinary own edit through the sync engine.
No more clash with unsent typing (conflict copies), the link shows at
once also with polling, and Strg+Z restores the branch. Undoing (toast or
Strg+Z) puts the new note into the trash via POST /extract/undo. The
sidebar gets the new note right away and opens its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 09:18:25 +01:00
1 parent 02ce554c02
commit 362dd3b4c6
10 files changed
+163 -71

No files matched your search

@@ -47,6 +47,9 @@ export interface DocumentEditorProps {
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -194,6 +197,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onOpenTask,
onSaveStatusChange,
onRequestMerge,
onDocCreated,
onDocRemoved,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
@@ -381,37 +386,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
// 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 [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => {
const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => {
setNotice(message);
setNoticeAction(action || null);
setNoticeActions(actions);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => {
setNotice(null);
setNoticeAction(null);
}, action ? 7000 : 3500);
setNoticeActions([]);
}, actions.length ? 8000 : 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]
);
// 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(() => {
const info = lastExtractRef.current;
if (!info) return;
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
if (block && block.content === info.link) return; // the undone step was something else
lastExtractRef.current = null;
api
.undoExtract(info.newDocId)
.then(() => onDocRemoved?.(info.newDocId))
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
}, [onDocRemoved, showNotice]);
useEffect(() => () => {
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
}, []);
@@ -423,7 +422,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const handleUndo = useCallback(() => {
const res = engineRef.current?.undo();
if (res?.message) showNotice(res.message);
}, [showNotice]);
if (res?.ok) finishUndoExtract();
}, [showNotice, finishUndoExtract]);
const handleRedo = useCallback(() => {
const res = engineRef.current?.redo();
if (res?.message) showNotice(res.message);
@@ -449,6 +450,38 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[]
);
// A bullet with its children becomes a note of its own. The server only creates the note; the branch
// is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works.
const handleExtractBlock = useCallback(
async (blockId: string) => {
const engine = engineRef.current;
if (!engine) return;
try {
await engine.syncNow(); // the server reads the branch as typed so far
const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId);
// The focused field keeps its own text while typing; leave it so it shows the link
(document.activeElement as HTMLElement | null)?.blur?.();
const prev = currentBlocksRef.current;
const i = prev.findIndex((b) => b.id === blockId);
if (i >= 0) {
const level = prev[i].level || 0;
let end = i + 1;
while (end < prev.length && (prev[end].level || 0) > level) end++;
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
}
lastExtractRef.current = { newDocId: created.id, blockId, link };
onDocCreated?.(created);
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
{ label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) },
{ label: 'Rückgängig', run: () => handleUndo() },
]);
} catch (err: any) {
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
}
},
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
);
// Keep parent sidebar metadata in sync when blocks/tags change
useEffect(() => {
if (!docRef.current) return;
@@ -1126,19 +1159,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
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"
>
<span>{notice}</span>
{noticeAction && (
{noticeActions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
noticeAction.run();
setNotice(null);
setNoticeAction(null);
setNoticeActions([]);
a.run();
}}
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
>
{noticeAction.label}
{a.label}
</button>
)}
))}
</div>
)}
</div>
@@ -1622,6 +1622,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onDocCreated={(created) => {
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
if (created.folderPath) {
const parts = created.folderPath.split('/');
const open: Record<string, boolean> = {};
parts.forEach((_, i) => {
open[parts.slice(0, i + 1).join('/')] = true;
});
setExpandedFolders((prev) => ({ ...prev, ...open }));
}
onStatsChange();
}}
onDocRemoved={(id) => {
setDocs((prev) => prev.filter((d) => d.id !== id));
reloadTrash();
onStatsChange();
}}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
+8 -3
View File
@@ -709,14 +709,19 @@ 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`, {
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
method: 'POST',
body: JSON.stringify({ blockId, clientId }),
});
},
/** Puts a note created by extractBranch into the trash again. */
async undoExtract(newDocId: string): Promise<void> {
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
},
/** 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`, {