import {
ArrowLeft,
Download,
Edit2,
Expand,
FileText,
Folder,
FolderPlus,
Hash,
ListTree,
MoreHorizontal,
Minimize2,
Plus,
Search,
Trash2,
Upload,
X,
} from "lucide-react";
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
import { useStoredNumber } from "../../application/state/useStoredNumber";
import { useStoredString } from "../../application/state/useStoredString";
import { useAvailableFonts } from "../../application/state/fontStore";
import { resolveNoteFontFamily } from "../../domain/noteFonts";
import { NoteOutline } from "./NoteOutline";
import { NoteExportMenu } from "./NoteExportMenu";
import { NoteModeDropdown, NoteToolbar } from "./NoteToolbar";
import type { NoteSourceEditorHandle } from "./NoteSourceEditor";
import {
ancestorNoteGroupPaths,
buildVaultNoteMarkdownExportFiles,
cleanNoteGroupPath,
getNoteGroupParentPath,
isNoteGroupInside,
joinNoteGroupPath,
matchesVaultNoteSearch,
importMarkdownPayloadsToVaultNotes,
normalizeNoteGroups,
normalizeVaultNotes,
remapExpandedNoteGroupPaths,
replaceNoteGroupPrefix,
resolveMovedNoteGroupPath,
sanitizeNoteExportFileNamePart,
type MarkdownActionType,
type NoteHeadingItem,
type VaultNotesExportScope,
} from "../../domain/notes";
import { getNextVaultOrder, reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from "../../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
} from "../../infrastructure/config/storageKeys";
import { logger } from "../../lib/logger";
import { cn } from "../../lib/utils";
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "../terminalLayer/terminalSidePanelChrome";
import { readTextFile } from "../../lib/readTextFile";
import { buildTextFilesZipBlob } from "../../lib/textZip";
import type { Host, VaultNote } from "../../types";
import { Button } from "../ui/button";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { Input } from "../ui/input";
import { ScrollArea } from "../ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { toast } from "../ui/toast";
import {
VaultTreeGroupRow,
VaultTreeInlineRenameInput,
VaultTreeItemRow,
} from "../vault/VaultTreeRow";
import { VaultDeleteConfirmDialog } from "../vault/VaultDeleteConfirmDialog";
import {
clearVaultDropIndicator,
getVaultDropIntent,
getVaultDropPosition,
type VaultDropPosition,
hasVaultDragType,
markVaultDropIndicator,
markVaultInsideDropIndicator,
} from "../vault/vaultReorderDrag";
import type {
ActiveTextFormats,
InlineMarkdownEditorHandle,
NoteEditorMode,
} from "./noteEditorTypes";
import { EMPTY_ACTIVE_FORMATS } from "./noteEditorTypes";
import { NoteTitleInput } from "./NoteTitleInput";
const InlineMarkdownEditor = lazy(() =>
import("./InlineMarkdownEditor.lazy").then((module) => ({ default: module.InlineMarkdownEditor })),
);
/** Warm the MDXEditor chunk before Suspense. */
export function prefetchInlineMarkdownEditor(): void {
void import("./InlineMarkdownEditor.lazy");
}
interface NoteFolderNode {
name: string;
path: string;
children: NoteFolderNode[];
notes: VaultNote[];
}
type NotesToolbarPanel = "search" | null;
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
const noteMetadataPillClass = "inline-flex h-5 items-center gap-1 rounded-md bg-muted/70 px-2 text-[11px] font-medium leading-none";
const noteMetadataLabelClass = "translate-y-px";
const NOTES_TREE_DEFAULT_WIDTH = 300;
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
const NOTES_TREE_MIN_WIDTH = 160;
const NOTES_TREE_MAX_WIDTH = 520;
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
export function clampNotesTreeWidth(value: number): number {
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
}
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
value === "live"
? "edit"
: value === "edit" || value === "preview" || value === "source"
? value
: null;
export const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
value === "edit" || value === "preview" || value === "source";
const InlineMarkdownEditorFallback = () => (
);
export interface NotesManagerProps {
notes: VaultNote[];
noteGroups: string[];
hosts: Host[];
onUpdateNotes: (notes: VaultNote[]) => void;
onUpdateNoteGroups: (groups: string[]) => void;
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
displayMode?: "full" | "sidebar";
/** When false (hidden retained mount), flush any pending draft immediately. */
isActive?: boolean;
openNoteId?: string | null;
openNoteRequestId?: number | null;
/** Called after a one-shot openNoteId focus request has been applied. */
onOpenNoteIdHandled?: () => void;
}
type HoverActionMenuProps = {
children: React.ReactNode | ((closeMenu: () => void) => React.ReactNode);
className?: string;
};
const HoverActionMenu: React.FC = ({ children, className }) => {
const [open, setOpen] = useState(false);
const closeTimerRef = useRef(null);
const cancelClose = () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
const scheduleClose = () => {
cancelClose();
closeTimerRef.current = window.setTimeout(() => setOpen(false), 140);
};
useEffect(() => () => cancelClose(), []);
const closeMenu = () => {
cancelClose();
setOpen(false);
};
return (
{typeof children === "function" ? children(closeMenu) : children}
);
};
const createNote = (group: string | null, order: number): VaultNote => {
const now = Date.now();
return {
id: crypto.randomUUID(),
title: "",
content: "",
group: group || undefined,
createdAt: now,
updatedAt: now,
order,
};
};
const sortNoteItems = (items: VaultNote[]): VaultNote[] => sortByVaultOrder(items);
const sortFolderNodes = (
items: NoteFolderNode[],
groupOrderByPath: ReadonlyMap,
): NoteFolderNode[] =>
[...items]
.sort((a, b) => {
const orderA = groupOrderByPath.get(a.path);
const orderB = groupOrderByPath.get(b.path);
if (typeof orderA === "number" && typeof orderB === "number" && orderA !== orderB) {
return orderA - orderB;
}
if (typeof orderA === "number") return -1;
if (typeof orderB === "number") return 1;
return a.name.localeCompare(b.name);
})
.map((node) => ({
...node,
children: sortFolderNodes(node.children, groupOrderByPath),
notes: sortNoteItems(node.notes),
}));
const buildNoteTree = (groups: string[], notes: VaultNote[]): { children: NoteFolderNode[]; rootNotes: VaultNote[] } => {
const nodes = new Map();
const ensureNode = (path: string): NoteFolderNode => {
const cleanPath = cleanNoteGroupPath(path);
const existing = nodes.get(cleanPath);
if (existing) return existing;
const name = cleanPath.split("/").pop() || cleanPath;
const node: NoteFolderNode = { name, path: cleanPath, children: [], notes: [] };
nodes.set(cleanPath, node);
const parentPath = cleanPath.split("/").slice(0, -1).join("/");
if (parentPath) {
ensureNode(parentPath).children.push(node);
}
return node;
};
const allGroups = normalizeNoteGroups([
...groups,
...notes.map((note) => note.group).filter((group): group is string => Boolean(group)),
]);
allGroups.flatMap(ancestorNoteGroupPaths).forEach(ensureNode);
const rootNotes: VaultNote[] = [];
notes.forEach((note) => {
const group = note.group ? cleanNoteGroupPath(note.group) : "";
if (!group) {
rootNotes.push(note);
return;
}
ensureNode(group).notes.push(note);
});
return {
children: Array.from(nodes.values()).filter((node) => !node.path.includes("/")),
rootNotes,
};
};
export const getSelectedVaultNote = (notes: VaultNote[], selectedNoteId: string | null): VaultNote | null =>
selectedNoteId ? notes.find((note) => note.id === selectedNoteId) ?? null : null;
export const isNoteFolderTreeSelected = (
selectedGroup: string | null,
selectedNoteId: string | null,
groupPath: string,
): boolean => selectedNoteId === null && selectedGroup === groupPath;
export const getNoteActionTargetGroup = (
selectedNote: VaultNote | null,
selectedGroup: string | null,
): string | null => selectedNote?.group || selectedGroup || null;
export const getNoteSelectionState = (
note: VaultNote,
isSidebarMode: boolean,
): { selectedNoteId: string; selectedGroup: null; overlayNoteId: string | null } => ({
selectedNoteId: note.id,
selectedGroup: null,
overlayNoteId: isSidebarMode ? note.id : null,
});
export const getNoteGroupSelectionState = (
groupPath: string,
): { selectedNoteId: null; selectedGroup: string; overlayNoteId: null } => ({
selectedNoteId: null,
selectedGroup: groupPath,
overlayNoteId: null,
});
export const getFallbackNoteSelectionState = (
remainingNotes: VaultNote[],
isSidebarMode: boolean,
): { selectedNoteId: string | null; selectedGroup: null; overlayNoteId: null } => ({
selectedNoteId: isSidebarMode ? null : remainingNotes[0]?.id ?? null,
selectedGroup: null,
overlayNoteId: null,
});
export const getValidatedNoteSelectionState = (
notes: VaultNote[],
selectedNoteId: string | null,
selectedGroup: string | null,
isSidebarMode: boolean,
): { selectedNoteId: string | null; selectedGroup: null; overlayNoteId: null } | null => {
if (selectedNoteId && notes.some((note) => note.id === selectedNoteId)) return null;
if (selectedNoteId || (!isSidebarMode && !selectedGroup && notes.length > 0)) {
return getFallbackNoteSelectionState(notes, isSidebarMode);
}
return null;
};
export const getNotesGroupDropAction = (
sourceGroup: string | null,
targetGroup: string,
intent: VaultDropPosition | "inside",
): "ignore" | "inside" | "reorder" => {
if (!sourceGroup || sourceGroup === targetGroup || targetGroup.startsWith(`${sourceGroup}/`)) {
return "ignore";
}
return intent === "inside" ? "inside" : "reorder";
};
export const NotesManager: React.FC = ({
notes,
noteGroups,
hosts,
onUpdateNotes,
onUpdateNoteGroups,
onOpenHost,
displayMode = "full",
isActive = true,
openNoteId = null,
openNoteRequestId = null,
onOpenNoteIdHandled,
}) => {
const { t } = useI18n();
const { openExternal } = useApplicationBackend();
const isSidebarMode = displayMode === "sidebar";
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
? openNoteId
: null;
const [query, setQuery] = useState("");
const [selectedGroup, setSelectedGroup] = useState(null);
const [selectedNoteId, setSelectedNoteId] = useState(() => initialOpenNoteId ?? (isSidebarMode ? null : notes[0]?.id ?? null));
const [noteEditorMode, setNoteEditorMode] = useStoredString(
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
"edit",
isNoteEditorMode,
);
const [activeFormats, setActiveFormats] = useState(EMPTY_ACTIVE_FORMATS);
const [overlayNoteId, setOverlayNoteId] = useState(() => initialOpenNoteId);
const [expandedGroups, setExpandedGroups] = useState>(
() => new Set(notes.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : [])),
);
const [expandedPanel, setExpandedPanel] = useState(null);
const [creatingGroupParent, setCreatingGroupParent] = useState(undefined);
const [editingGroupPath, setEditingGroupPath] = useState(null);
const [editingNoteId, setEditingNoteId] = useState(null);
const [isTreeResizing, setIsTreeResizing] = useState(false);
const [draggingNoteId, setDraggingNoteId] = useState(null);
const [draggingGroupPath, setDraggingGroupPath] = useState(null);
const [deleteTarget, setDeleteTarget] = useState<{
type: "note" | "group";
id: string;
name: string;
} | null>(null);
const [showOutline, setShowOutline] = useState(false);
const [tagInputOpen, setTagInputOpen] = useState(false);
const [newTagText, setNewTagText] = useState("");
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
NOTES_TREE_DEFAULT_WIDTH,
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
);
const [noteFontFamily, setNoteFontFamily] = useStoredString(
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
"",
);
const availableNoteFonts = useAvailableFonts();
const resolvedNoteFontFamily = useMemo(
() => resolveNoteFontFamily(availableNoteFonts, noteFontFamily),
[availableNoteFonts, noteFontFamily],
);
const [noteFontSize, setNoteFontSize, persistNoteFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
14,
{ min: 10, max: 32 },
);
const handleSetNoteFontSize = useCallback((size: number) => {
setNoteFontSize(size);
persistNoteFontSize(size);
}, [persistNoteFontSize, setNoteFontSize]);
const [noteCodeFontSize, setNoteCodeFontSize, persistNoteCodeFontSize] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
13,
{ min: 10, max: 32 },
);
const handleSetNoteCodeFontSize = useCallback((size: number) => {
setNoteCodeFontSize(size);
persistNoteCodeFontSize(size);
}, [persistNoteCodeFontSize, setNoteCodeFontSize]);
const treeAsideRef = useRef(null);
const treeWidthRef = useRef(treeWidth);
treeWidthRef.current = treeWidth;
const searchInputRef = useRef(null);
const importFileInputRef = useRef(null);
const isImportingMarkdownRef = useRef(false);
const importTargetGroupRef = useRef(undefined);
const sortedNotesRef = useRef([]);
const activeDownloadUrlsRef = useRef>(new Set());
const groups = useMemo(() => normalizeNoteGroups(noteGroups), [noteGroups]);
const groupOrderByPath = useMemo(
() => new Map(groups.map((group, index) => [group, index])),
[groups],
);
const sortedNotes = useMemo(() => sortNoteItems(normalizeVaultNotes(notes)), [notes]);
sortedNotesRef.current = sortedNotes;
const commitNotes = useCallback((nextNotes: VaultNote[]) => {
const cleaned = normalizeVaultNotes(nextNotes);
sortedNotesRef.current = cleaned;
onUpdateNotes(cleaned);
return cleaned;
}, [onUpdateNotes]);
const addTagToNote = useCallback((noteId: string, tag: string) => {
const clean = tag.trim();
if (!clean) return;
commitNotes(sortedNotesRef.current.map((n) => {
if (n.id !== noteId) return n;
const existing = n.tags ?? [];
if (existing.includes(clean)) return n;
return { ...n, tags: [...existing, clean], updatedAt: Date.now() };
}));
}, [commitNotes]);
const removeTagFromNote = useCallback((noteId: string, tagToRemove: string) => {
commitNotes(sortedNotesRef.current.map((n) => {
if (n.id !== noteId) return n;
const existing = n.tags ?? [];
const nextTags = existing.filter((t) => t !== tagToRemove);
return { ...n, tags: nextTags.length ? nextTags : undefined, updatedAt: Date.now() };
}));
}, [commitNotes]);
const NOTE_DRAFT_DEBOUNCE_MS = 300;
const draftNoteIdRef = useRef(null);
const draftTitleRef = useRef(null);
const draftContentRef = useRef(null);
const draftTimerRef = useRef(null);
const [draftNoteId, setDraftNoteId] = useState(null);
const [draftTitle, setDraftTitle] = useState(null);
const clearDraftTimer = useCallback(() => {
if (draftTimerRef.current !== null) {
window.clearTimeout(draftTimerRef.current);
draftTimerRef.current = null;
}
}, []);
const flushNoteDraft = useCallback(() => {
clearDraftTimer();
const noteId = draftNoteIdRef.current;
if (!noteId) return;
const title = draftTitleRef.current;
const content = draftContentRef.current;
draftNoteIdRef.current = null;
draftTitleRef.current = null;
draftContentRef.current = null;
setDraftNoteId(null);
setDraftTitle(null);
if (title === null && content === null) return;
commitNotes(sortedNotesRef.current.map((note) => {
if (note.id !== noteId) return note;
return {
...note,
...(title !== null ? { title } : {}),
...(content !== null ? { content } : {}),
updatedAt: Date.now(),
};
}));
}, [clearDraftTimer, commitNotes]);
const scheduleNoteDraftFlush = useCallback(() => {
clearDraftTimer();
draftTimerRef.current = window.setTimeout(() => {
draftTimerRef.current = null;
flushNoteDraft();
}, NOTE_DRAFT_DEBOUNCE_MS);
}, [clearDraftTimer, flushNoteDraft]);
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
flushNoteDraft();
}
draftNoteIdRef.current = noteId;
if (fields.title !== undefined) {
draftTitleRef.current = fields.title;
setDraftNoteId(noteId);
setDraftTitle(fields.title);
}
if (fields.content !== undefined) {
draftContentRef.current = fields.content;
}
scheduleNoteDraftFlush();
}, [flushNoteDraft, scheduleNoteDraftFlush]);
const flushNoteDraftRef = useRef(flushNoteDraft);
flushNoteDraftRef.current = flushNoteDraft;
useEffect(() => () => {
flushNoteDraftRef.current();
}, []);
useLayoutEffect(() => {
if (isActive) return;
flushNoteDraft();
}, [flushNoteDraft, isActive]);
useEffect(() => {
if (!isActive) return;
prefetchInlineMarkdownEditor();
}, [isActive]);
useEffect(() => {
const flushOnTeardown = () => {
flushNoteDraft();
};
const flushOnHidden = () => {
if (document.visibilityState === "hidden") flushNoteDraft();
};
window.addEventListener("pagehide", flushOnTeardown);
window.addEventListener("beforeunload", flushOnTeardown);
document.addEventListener("visibilitychange", flushOnHidden);
return () => {
window.removeEventListener("pagehide", flushOnTeardown);
window.removeEventListener("beforeunload", flushOnTeardown);
document.removeEventListener("visibilitychange", flushOnHidden);
};
}, [flushNoteDraft]);
const noteTree = useMemo(() => {
const tree = buildNoteTree(groups, sortedNotes);
return {
children: sortFolderNodes(tree.children, groupOrderByPath),
rootNotes: sortNoteItems(tree.rootNotes),
};
}, [groupOrderByPath, groups, sortedNotes]);
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
const selectedNoteView = useMemo(() => {
if (!selectedNote) return null;
if (draftNoteId === selectedNote.id && draftTitle !== null) {
return { ...selectedNote, title: draftTitle };
}
return selectedNote;
}, [draftNoteId, draftTitle, selectedNote]);
const overlayNoteView = useMemo(() => {
if (!overlayNote) return null;
if (draftNoteId === overlayNote.id && draftTitle !== null) {
return { ...overlayNote, title: draftTitle };
}
return overlayNote;
}, [draftNoteId, draftTitle, overlayNote]);
useEffect(() => {
if (!draftNoteIdRef.current) return;
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
flushNoteDraft();
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
useEffect(() => {
const urls = activeDownloadUrlsRef.current;
return () => {
urls.forEach((url) => URL.revokeObjectURL(url));
urls.clear();
};
}, []);
const queryText = query.trim();
const queryLower = queryText.toLowerCase();
const noteMatches = (note: VaultNote) => matchesVaultNoteSearch(note, queryText, hosts);
const groupMatches = (node: NoteFolderNode) =>
!queryLower || node.name.toLowerCase().includes(queryLower) || node.path.toLowerCase().includes(queryLower);
useEffect(() => {
const nextSelection = getValidatedNoteSelectionState(sortedNotes, selectedNoteId, selectedGroup, isSidebarMode);
if (!nextSelection) return;
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
}, [isSidebarMode, selectedGroup, selectedNoteId, sortedNotes]);
useEffect(() => {
if (!overlayNoteId || sortedNotes.some((note) => note.id === overlayNoteId)) return;
setOverlayNoteId(null);
}, [overlayNoteId, sortedNotes]);
useEffect(() => {
if (!selectedNote?.group) return;
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(selectedNote.group || "")]));
}, [selectedNote?.group]);
useEffect(() => {
if (!openNoteId) return;
const note = sortedNotes.find((item) => item.id === openNoteId);
if (!note) return;
const nextSelection = getNoteSelectionState(note, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
if (note.group) {
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(note.group || "")]));
}
onOpenNoteIdHandled?.();
}, [isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes]);
useEffect(() => {
if (expandedPanel !== "search") return;
const frame = requestAnimationFrame(() => {
searchInputRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [expandedPanel]);
const expandPath = (path: string) => {
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(path)]));
};
const toggleGroup = (path: string) => {
setExpandedGroups((current) => {
const next = new Set(current);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const allGroupPaths = useMemo(() => {
const paths: string[] = [];
const visit = (nodes: NoteFolderNode[]) => {
nodes.forEach((node) => {
paths.push(node.path);
visit(node.children);
});
};
visit(noteTree.children);
return paths;
}, [noteTree.children]);
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
const collapseAllGroups = () => setExpandedGroups(new Set());
/** Flush pending draft, then mutate from the post-flush ref snapshot. */
const commitNotesAfterFlush = useCallback((mutator: (notes: VaultNote[]) => VaultNote[]) => {
flushNoteDraft();
return commitNotes(mutator(sortedNotesRef.current));
}, [commitNotes, flushNoteDraft]);
/** Rename from the tree row: merge title into the post-flush note so an
* unflushed body edit is not discarded by a stale tree-row snapshot. */
const renameNoteFromTree = (noteId: string, title: string) => {
const nextTitle = title.trim();
const updatedAt = Date.now();
commitNotesAfterFlush((notes) => notes.map((note) => (
note.id === noteId ? { ...note, title: nextTitle, updatedAt } : note
)));
};
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
updateNoteDraft(note.id, { title });
};
/** Ref-only title stash for IME composition — avoids controlled value rewrite. */
const stashNoteTitleDraft = (note: VaultNote, title: string) => {
if (draftNoteIdRef.current && draftNoteIdRef.current !== note.id) {
flushNoteDraft();
}
// Cancel any idle-commit debounce so a prior ASCII commit cannot flush
// mid-composition and rewrite the controlled title.
clearDraftTimer();
draftNoteIdRef.current = note.id;
draftTitleRef.current = title;
};
const saveNoteContentDraft = useCallback((noteId: string, content: string) => {
updateNoteDraft(noteId, { content });
}, [updateNoteDraft]);
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
onOpenHost?.(host, { noteId });
}, [onOpenHost]);
const sourceEditorRef = useRef(null);
const inlineEditorRef = useRef(null);
const overlayEditorRef = useRef(null);
const handleToolbarAction = useCallback((action: MarkdownActionType) => {
if (isSidebarMode && overlayNoteView) {
overlayEditorRef.current?.executeAction(action);
} else {
inlineEditorRef.current?.executeAction(action);
}
}, [isSidebarMode, overlayNoteView]);
const handleOutlineHeadingSelect = useCallback((heading: NoteHeadingItem, index: number) => {
inlineEditorRef.current?.scrollToHeading(heading, index);
}, []);
const addNoteToGroup = (group: string | null) => {
let created: VaultNote | null = null;
commitNotesAfterFlush((notes) => {
created = createNote(group, getNextVaultOrder(notes));
return [...notes, created];
});
if (!created) return;
if (group) expandPath(group);
const nextSelection = getNoteSelectionState(created, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
};
const addNote = () => {
addNoteToGroup(getNoteActionTargetGroup(selectedNote, selectedGroup));
};
const openImportMarkdownPicker = useCallback((targetGroupOverride?: string | null) => {
importTargetGroupRef.current = targetGroupOverride;
importFileInputRef.current?.click();
}, []);
const handleImportMarkdownFiles = useCallback(async (fileList: FileList | null) => {
const resetImportInput = () => {
if (importFileInputRef.current) {
importFileInputRef.current.value = "";
}
};
if (!fileList || fileList.length === 0) {
resetImportInput();
return;
}
if (isImportingMarkdownRef.current) {
toast.info(t("notes.import.toast.inProgress"));
resetImportInput();
return;
}
const files = Array.from(fileList);
const markdownFiles = files.filter((file) => /\.(md|markdown|txt)$/i.test(file.name));
const skippedCount = files.length - markdownFiles.length;
const pendingTargetGroup = importTargetGroupRef.current;
importTargetGroupRef.current = undefined;
const targetGroup = pendingTargetGroup !== undefined
? pendingTargetGroup
: getNoteActionTargetGroup(selectedNote, selectedGroup);
isImportingMarkdownRef.current = true;
try {
if (markdownFiles.length === 0) {
toast.error(t("notes.import.toast.noNotes"));
return;
}
const payloads = await Promise.all(
markdownFiles.map(async (file) => ({
fileName: file.name,
content: await readTextFile(file),
})),
);
const result = importMarkdownPayloadsToVaultNotes(
payloads,
(() => {
flushNoteDraft();
return sortedNotesRef.current;
})(),
targetGroup,
);
if (result.importedCount === 0) {
toast.error(t("notes.import.toast.noNotes"));
return;
}
const mergedNotes = commitNotes(result.notes);
if (targetGroup) expandPath(targetGroup);
const lastImported = mergedNotes[mergedNotes.length - 1];
const nextSelection = getNoteSelectionState(lastImported, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
toast.success(t("notes.import.toast.success", { count: result.importedCount }));
if (skippedCount > 0) {
toast.info(t("notes.import.toast.skipped", { count: skippedCount }));
}
} catch (err) {
logger.error("Failed to import markdown files:", err);
toast.error(t("notes.import.toast.failed"));
} finally {
isImportingMarkdownRef.current = false;
resetImportInput();
}
}, [
isSidebarMode,
commitNotes,
flushNoteDraft,
selectedGroup,
selectedNote,
t,
]);
const downloadNotesBlob = useCallback((blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
activeDownloadUrlsRef.current.add(url);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => {
URL.revokeObjectURL(url);
activeDownloadUrlsRef.current.delete(url);
}, 60_000);
}, []);
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
flushNoteDraft();
const latest = sortedNotesRef.current.find((item) => item.id === note.id) ?? note;
try {
const fileName = `${sanitizeNoteExportFileNamePart(latest.title, "note")}.md`;
downloadNotesBlob(
new Blob([latest.content], { type: "text/markdown;charset=utf-8" }),
fileName,
);
toast.success(t("notes.export.toast.success", { count: 1 }));
} catch (err) {
logger.error("Failed to export note:", err);
toast.error(t("notes.export.toast.failed"));
}
}, [downloadNotesBlob, flushNoteDraft, t]);
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
flushNoteDraft();
try {
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
if (files.length === 0) {
toast.warning(t("notes.export.toast.empty"));
return;
}
const blob = buildTextFilesZipBlob(files);
const safeName = sanitizeNoteExportFileNamePart(fileNamePart, "notes");
downloadNotesBlob(blob, `netcatty-notes-${safeName}.zip`);
toast.success(t("notes.export.toast.success", { count: files.length }));
} catch (err) {
logger.error("Failed to export notes:", err);
toast.error(t("notes.export.toast.failed"));
}
}, [downloadNotesBlob, flushNoteDraft, t]);
const exportAllNotes = useCallback(() => {
exportNotesToZip({ type: "all" }, "all");
}, [exportNotesToZip]);
const exportGroupNotes = useCallback((groupPath: string) => {
exportNotesToZip({ type: "group", group: groupPath }, groupPath);
}, [exportNotesToZip]);
const duplicateNoteById = (noteId: string) => {
flushNoteDraft();
const source = sortedNotesRef.current.find((note) => note.id === noteId);
if (!source) return;
const now = Date.now();
const copy: VaultNote = {
...source,
id: crypto.randomUUID(),
title: `${source.title} (${t("action.copy")})`,
createdAt: now,
updatedAt: now,
order: getNextVaultOrder(sortedNotesRef.current),
};
commitNotes([...sortedNotesRef.current, copy]);
if (copy.group) expandPath(copy.group);
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
};
const performDeleteNoteById = (noteId: string) => {
const next = commitNotesAfterFlush((notes) => notes.filter((note) => note.id !== noteId));
if (selectedNoteId === noteId) {
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
setEditingNoteId(null);
}
if (overlayNoteId === noteId) setOverlayNoteId(null);
};
const requestDeleteNoteById = (noteId: string) => {
const note = sortedNotes.find((item) => item.id === noteId);
setDeleteTarget({
type: "note",
id: noteId,
name: note?.title || t("notes.title.placeholder"),
});
};
const startCreateGroup = () => {
const targetGroup = getNoteActionTargetGroup(selectedNote, selectedGroup);
setCreatingGroupParent(targetGroup);
if (targetGroup) expandPath(targetGroup);
};
const commitCreateGroup = (name: string) => {
const nextPath = joinNoteGroupPath(creatingGroupParent ?? null, name);
setCreatingGroupParent(undefined);
if (!nextPath) return;
const next = normalizeNoteGroups([...groups, ...ancestorNoteGroupPaths(nextPath)]);
onUpdateNoteGroups(next);
expandPath(nextPath);
const nextSelection = getNoteGroupSelectionState(nextPath);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
};
const renameGroup = (group: string, nextName: string) => {
setEditingGroupPath(null);
const nextPath = joinNoteGroupPath(getNoteGroupParentPath(group), nextName);
if (!nextPath || nextPath === group) return;
const nextGroups = normalizeNoteGroups(
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
);
onUpdateNoteGroups(nextGroups);
commitNotesAfterFlush((notes) => notes.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, group, nextPath),
})));
setExpandedGroups((current) => {
const next = new Set();
current.forEach((item) => {
const renamed = replaceNoteGroupPrefix(item, group, nextPath);
if (renamed) next.add(renamed);
});
ancestorNoteGroupPaths(nextPath).forEach((path) => next.add(path));
return next;
});
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
}
};
const performDeleteGroup = (group: string) => {
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
commitNotesAfterFlush((notes) => notes.map((note) => (
isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note
)));
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
setEditingGroupPath(null);
};
const requestDeleteGroup = (group: string) => {
setDeleteTarget({
type: "group",
id: group,
name: group,
});
};
const confirmDeleteTarget = () => {
if (!deleteTarget) return;
if (deleteTarget.type === "note") {
performDeleteNoteById(deleteTarget.id);
} else {
performDeleteGroup(deleteTarget.id);
}
setDeleteTarget(null);
};
const resetTreeDragState = () => {
setDraggingNoteId(null);
setDraggingGroupPath(null);
clearVaultDropIndicator();
};
const getDraggedNoteId = (dataTransfer: DataTransfer) =>
dataTransfer.getData(NOTE_DRAG_TYPE) || dataTransfer.getData("note-id");
const getDraggedGroupPath = (dataTransfer: DataTransfer) =>
dataTransfer.getData(NOTE_GROUP_DRAG_TYPE) || dataTransfer.getData("note-group-path");
const hasNotesTreeDrag = (dataTransfer: DataTransfer) =>
draggingNoteId
|| draggingGroupPath
|| hasVaultDragType(dataTransfer, NOTE_DRAG_TYPE)
|| hasVaultDragType(dataTransfer, NOTE_GROUP_DRAG_TYPE)
|| hasVaultDragType(dataTransfer, "note-id")
|| hasVaultDragType(dataTransfer, "note-group-path");
const handleTreeRowDragLeave = (event: React.DragEvent) => {
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) return;
clearVaultDropIndicator();
};
const moveNoteToGroup = (noteId: string, group: string | null) => {
flushNoteDraft();
const source = sortedNotesRef.current.find((note) => note.id === noteId);
if (!source) return;
const nextGroup = group || undefined;
if ((source.group || undefined) === nextGroup) return;
commitNotes(sortedNotesRef.current.map((note) => (
note.id === noteId ? { ...note, group: nextGroup, updatedAt: Date.now() } : note
)));
if (group) expandPath(group);
};
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent) => {
if (!sourceId || sourceId === targetNote.id) return;
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
commitNotesAfterFlush((notes) => {
const movedNotes = notes.map((note) => (
note.id === sourceId
? { ...note, group: targetNote.group, updatedAt: Date.now() }
: note
));
return reorderVaultItems(movedNotes, sourceId, targetNote.id, position);
});
if (targetNote.group) expandPath(targetNote.group);
};
const moveGroupToParent = (group: string, parent: string | null) => {
flushNoteDraft();
const knownGroups = normalizeNoteGroups([
...groups,
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
]);
const nextPath = resolveMovedNoteGroupPath(group, parent, knownGroups);
if (!nextPath) return;
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
onUpdateNoteGroups(nextGroups);
commitNotes(sortedNotesRef.current.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, group, nextPath),
})));
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
}
};
const reorderGroupToGroup = (
sourceGroup: string,
targetGroup: string,
position: VaultDropPosition,
) => {
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
flushNoteDraft();
const targetParent = getNoteGroupParentPath(targetGroup);
const knownGroups = normalizeNoteGroups([
...groups,
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
]);
const nextSourceGroup = getNoteGroupParentPath(sourceGroup) === targetParent
? cleanNoteGroupPath(sourceGroup)
: resolveMovedNoteGroupPath(sourceGroup, targetParent, knownGroups);
if (!nextSourceGroup) return;
const nextGroupsBeforeReorder = normalizeNoteGroups([
...groups.map((item) => replaceNoteGroupPrefix(item, sourceGroup, nextSourceGroup) || ""),
...ancestorNoteGroupPaths(nextSourceGroup),
...ancestorNoteGroupPaths(targetGroup),
]);
const nextGroups = reorderVaultStrings(
nextGroupsBeforeReorder,
nextSourceGroup,
targetGroup,
position,
);
onUpdateNoteGroups(nextGroups);
if (nextSourceGroup !== sourceGroup) {
commitNotes(sortedNotesRef.current.map((note) => ({
...note,
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
})));
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
}
}
};
const handleGroupDrop = (targetGroup: string | null, event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const noteId = getDraggedNoteId(event.dataTransfer);
const groupPath = getDraggedGroupPath(event.dataTransfer);
if (noteId) moveNoteToGroup(noteId, targetGroup);
if (groupPath) moveGroupToParent(groupPath, targetGroup);
resetTreeDragState();
};
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => {
const actions = [
{
label: t("common.rename"),
action: () => setEditingNoteId(note.id),
},
{
label: t("action.copy"),
action: () => duplicateNoteById(note.id),
},
{
label: t("notes.action.exportNote"),
action: () => exportNoteToMarkdown(note),
},
{
label: t("action.delete"),
action: () => requestDeleteNoteById(note.id),
destructive: true,
},
];
if (mode === "context") {
return actions.map((action) => (
{
action.action();
}}
>
{action.label}
));
}
return actions.map((action) => (
));
};
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context", closeMenu?: () => void) => {
const actions = [
{
label: t("notes.action.newNote"),
action: () => addNoteToGroup(groupPath),
},
{
label: t("notes.action.newGroup"),
action: () => {
setCreatingGroupParent(groupPath);
expandPath(groupPath);
},
},
{
label: t("notes.action.importMarkdown"),
action: () => openImportMarkdownPicker(groupPath),
},
{
label: t("notes.action.exportGroup"),
action: () => exportGroupNotes(groupPath),
},
{
label: t("common.rename"),
action: () => setEditingGroupPath(groupPath),
},
{
label: t("action.delete"),
action: () => requestDeleteGroup(groupPath),
destructive: true,
},
];
if (mode === "context") {
return actions.map((action) => (
{
action.action();
}}
>
{action.label}
));
}
return actions.map((action) => (
));
};
const renderCreateGroupRow = (parent: string | null, depth: number) => {
if (creatingGroupParent !== parent) return null;
return (
setCreatingGroupParent(undefined)}
/>
);
};
const noteDisplayTitle = (title: string) => title || t("notes.title.placeholder");
const renderNoteRow = (note: VaultNote, depth: number) => {
if (!noteMatches(note)) return null;
return (
{
setEditingNoteId(null);
renameNoteFromTree(note.id, name);
}}
onRenameCancel={() => setEditingNoteId(null)}
icon={}
iconClassName="mr-1"
data-note-id={note.id}
data-notes-drag-kind="note"
data-notes-context-menu="note"
data-vault-reorder-dragging={draggingNoteId === note.id ? "true" : undefined}
draggable={editingNoteId !== note.id}
onDragStart={(event) => {
event.dataTransfer.setData(NOTE_DRAG_TYPE, note.id);
event.dataTransfer.setData("note-id", note.id);
event.dataTransfer.effectAllowed = "move";
setDraggingNoteId(note.id);
}}
onDragOver={(event) => {
const sourceNoteId = draggingNoteId || getDraggedNoteId(event.dataTransfer);
if (!sourceNoteId || sourceNoteId === note.id) {
clearVaultDropIndicator();
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
markVaultDropIndicator(
event.currentTarget,
getVaultDropPosition(event.currentTarget, event.clientX, event.clientY),
);
}}
onDragLeave={handleTreeRowDragLeave}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
reorderNoteToNote(draggingNoteId || getDraggedNoteId(event.dataTransfer), note, event);
resetTreeDragState();
}}
onDragEnd={resetTreeDragState}
onClick={() => {
const nextSelection = getNoteSelectionState(note, isSidebarMode);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
}}
actions={(
{(closeMenu) => renderNoteActions(note, "dropdown", closeMenu)}
)}
/>
{renderNoteActions(note, "context")}
);
};
const renderFolderRow = (node: NoteFolderNode, depth: number): React.ReactNode => {
const folderMatchesQuery = groupMatches(node);
const visibleNotes = folderMatchesQuery ? node.notes : node.notes.filter(noteMatches);
const visibleChildren = node.children
.map((child) => renderFolderRow(child, depth + 1))
.filter(Boolean);
if (queryText && !folderMatchesQuery && visibleNotes.length === 0 && visibleChildren.length === 0) {
return null;
}
const expanded = queryText ? true : expandedGroups.has(node.path);
const hasChildren = node.children.length > 0 || node.notes.length > 0;
return (
renameGroup(node.path, name)}
onRenameCancel={() => setEditingGroupPath(null)}
data-note-group-path={node.path}
data-notes-drag-kind="group"
data-notes-context-menu="group"
data-vault-reorder-dragging={draggingGroupPath === node.path ? "true" : undefined}
draggable={editingGroupPath !== node.path}
onDragStart={(event) => {
event.dataTransfer.setData(NOTE_GROUP_DRAG_TYPE, node.path);
event.dataTransfer.setData("note-group-path", node.path);
event.dataTransfer.effectAllowed = "move";
setDraggingGroupPath(node.path);
}}
onDragOver={(event) => {
const sourceNoteId = draggingNoteId || getDraggedNoteId(event.dataTransfer);
const sourceGroupPath = draggingGroupPath || getDraggedGroupPath(event.dataTransfer);
if (!sourceNoteId && !sourceGroupPath) {
clearVaultDropIndicator();
return;
}
if (sourceGroupPath && (sourceGroupPath === node.path || node.path.startsWith(`${sourceGroupPath}/`))) {
clearVaultDropIndicator();
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
if (sourceGroupPath) {
const intent = getVaultDropIntent(event.currentTarget, event.clientX, event.clientY, false);
if (intent === "inside") {
markVaultInsideDropIndicator(event.currentTarget);
} else {
markVaultDropIndicator(event.currentTarget, intent);
}
return;
}
markVaultInsideDropIndicator(event.currentTarget);
}}
onDragLeave={handleTreeRowDragLeave}
onDrop={(event) => {
const sourceGroupPath = draggingGroupPath || getDraggedGroupPath(event.dataTransfer);
if (sourceGroupPath) {
const intent = getVaultDropIntent(event.currentTarget, event.clientX, event.clientY, false);
const dropAction = getNotesGroupDropAction(sourceGroupPath, node.path, intent);
if (dropAction === "reorder" && intent !== "inside") {
event.preventDefault();
event.stopPropagation();
reorderGroupToGroup(sourceGroupPath, node.path, intent);
resetTreeDragState();
return;
}
if (dropAction === "ignore") return;
}
handleGroupDrop(node.path, event);
}}
onDragEnd={resetTreeDragState}
onClick={() => {
const nextSelection = getNoteGroupSelectionState(node.path);
setSelectedNoteId(nextSelection.selectedNoteId);
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
if (hasChildren) toggleGroup(node.path);
}}
actions={(
{(closeMenu) => renderGroupActions(node.path, "dropdown", closeMenu)}
)}
/>
{renderGroupActions(node.path, "context")}
{expanded && (
<>
{renderCreateGroupRow(node.path, depth + 1)}
{visibleChildren}
{visibleNotes.map((note) => renderNoteRow(note, depth + 1))}
>
)}
);
};
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
const visibleTree = noteTree.children
.map((child) => renderFolderRow(child, 0))
.filter(Boolean);
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
const hasSearch = query.trim().length > 0;
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
const handleTreeResizeStart = useCallback((event: React.PointerEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = treeWidthRef.current;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const aside = treeAsideRef.current;
let frame = 0;
let latestWidth = startWidth;
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
// re-rendering it per pixel makes sidebar resize feel stuck.
setIsTreeResizing(true);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (aside) {
aside.style.willChange = "width";
}
const applyWidth = (width: number) => {
latestWidth = width;
if (aside) {
aside.style.width = `${width}px`;
}
};
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
if (frame) {
latestWidth = nextWidth;
return;
}
latestWidth = nextWidth;
frame = window.requestAnimationFrame(() => {
frame = 0;
applyWidth(latestWidth);
});
};
const handlePointerUp = () => {
if (frame) {
window.cancelAnimationFrame(frame);
frame = 0;
}
const nextWidth = clampNotesTreeWidth(latestWidth);
applyWidth(nextWidth);
if (aside) {
aside.style.willChange = "";
}
treeWidthRef.current = nextWidth;
setTreeWidth(nextWidth);
persistTreeWidth(nextWidth);
setIsTreeResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, [persistTreeWidth, setTreeWidth]);
return (
{
void handleImportMarkdownFiles(event.target.files);
}}
/>
{shouldShowNotesTree && (
)}
{!isSidebarMode && (
{selectedNoteView ? (
<>
saveNoteTitleDraft(selectedNoteView, title)}
onLiveDraft={(title) => stashNoteTitleDraft(selectedNoteView, title)}
onBlur={() => flushNoteDraft()}
/>
{
flushNoteDraft();
setNoteEditorMode(mode);
}}
/>
{/* Outline Toggle */}
{t("notes.outline.title")}
{/* Export Menu */}
{/* Delete Note */}
{t("action.delete")}
{/* Tags & Folder Breadcrumb Header */}
{selectedNoteView.group && (
{selectedNoteView.group}
)}
{selectedNoteView.tags?.map((tag) => (
{tag}
))}
{tagInputOpen ? (
setNewTagText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (newTagText.trim()) {
addTagToNote(selectedNoteView.id, newTagText.trim());
setNewTagText("");
setTagInputOpen(false);
}
} else if (e.key === "Escape") {
setTagInputOpen(false);
setNewTagText("");
}
}}
onBlur={() => {
if (newTagText.trim()) {
addTagToNote(selectedNoteView.id, newTagText.trim());
}
setTagInputOpen(false);
setNewTagText("");
}}
placeholder={t("notes.tag.placeholder")}
className="w-20 translate-y-px bg-transparent text-[11px] text-foreground outline-none placeholder:text-muted-foreground/60"
/>
) : (
)}
{/* Cherry Studio Style Note Toolbar */}
setNoteFontFamily(font)}
noteFontSize={noteFontSize}
onChangeNoteFontSize={handleSetNoteFontSize}
noteCodeFontSize={noteCodeFontSize}
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
/>
{noteEditorMode === "source" ? (
{
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
}>
saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
) : (
{
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
}>
saveNoteContentDraft(selectedNoteView.id, content)}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
)}
{showOutline && (
setShowOutline(false)}
/>
)}
>
) : (
{t("notes.empty.title")}
{t("notes.empty.desc")}
)}
)}
{isSidebarMode && overlayNoteView && (
{t("common.back")}
{overlayNoteView.title || t("notes.title.placeholder")}
saveNoteTitleDraft(overlayNoteView, title)}
onLiveDraft={(title) => stashNoteTitleDraft(overlayNoteView, title)}
onBlur={() => flushNoteDraft()}
/>
{
flushNoteDraft();
setNoteEditorMode(mode);
}}
/>
{/* Note Toolbar in Overlay */}
setNoteFontFamily(font)}
noteFontSize={noteFontSize}
onChangeNoteFontSize={handleSetNoteFontSize}
noteCodeFontSize={noteCodeFontSize}
onChangeNoteCodeFontSize={handleSetNoteCodeFontSize}
/>
{noteEditorMode === "source" ? (
{
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
}>
saveNoteContentDraft(overlayNoteView.id, content)}
previewEmptyLabel={t("notes.preview.empty")}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
) : (
{
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
flushNoteDraft();
}
}}
>
}>
saveNoteContentDraft(overlayNoteView.id, content)}
previewEmptyLabel={t("notes.preview.empty")}
hosts={hosts}
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
onOpenExternalLink={openExternal}
sourceEditorRef={sourceEditorRef}
noteFontFamily={resolvedNoteFontFamily}
noteFontSize={noteFontSize}
noteCodeFontSize={noteCodeFontSize}
onActiveFormatsChange={setActiveFormats}
/>
)}
)}
{
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
);
};