Merge remote-tracking branch 'origin/dev' into feat/gemini-workspace
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
fc7b2b3a2c
10 files changed
+161
-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) => {
|
||||
|
||||
@@ -723,14 +723,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`, {
|
||||
|
||||
Reference in new issue
Block a user