Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,47 @@

import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import {
MdFormatBold,
MdFormatItalic,
MdFormatStrikethrough,
MdCode,
MdTitle,
MdFormatListBulleted,
MdFormatListNumbered,
MdFormatQuote,
MdHorizontalRule,
MdUndo,
MdRedo,
MdTextFields,
} from "react-icons/md";

function ToolbarButton({
label,
icon,
onClick,
active = false,
disabled = false,
title,
}: {
label: string;
icon: React.ReactNode;
onClick: () => void;
active?: boolean;
disabled?: boolean;
title: string;
}) {
const className = [
"inline-flex min-h-9 items-center rounded-lg border px-3 text-sm font-medium transition-colors",
active
? "border-blue-600 bg-blue-600 text-white"
: "border-slate-200 bg-white text-slate-700 hover:bg-slate-50",
disabled ? "cursor-not-allowed opacity-50 hover:bg-white" : "",
]
.filter(Boolean)
.join(" ");

return (
<button
className={className}
disabled={disabled}
onClick={onClick}
disabled={disabled}
title={title}
type="button"
className={`w-8 h-8 flex items-center justify-center rounded transition-colors ${
active
? "bg-blue-100 text-blue-600"
: "text-gray-600 hover:bg-gray-100"
} ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
>
{label}
{icon}
</button>
);
}
Expand Down Expand Up @@ -85,92 +96,102 @@ export default function FormattingToolbar({ editor }: { editor: Editor }) {
});

return (
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-0.5 flex-wrap">
<ToolbarButton
icon={<MdFormatBold size={16} />}
onClick={() => editor.chain().focus().toggleBold().run()}
active={editorState.isBold}
disabled={!editorState.canBold}
label="粗体"
onClick={() => editor.chain().focus().toggleBold().run()}
title="粗体"
/>
<ToolbarButton
icon={<MdFormatItalic size={16} />}
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editorState.isItalic}
disabled={!editorState.canItalic}
label="斜体"
onClick={() => editor.chain().focus().toggleItalic().run()}
title="斜体"
/>
<ToolbarButton
icon={<MdFormatStrikethrough size={16} />}
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editorState.isStrike}
disabled={!editorState.canStrike}
label="删除线"
onClick={() => editor.chain().focus().toggleStrike().run()}
title="删除线"
/>
<ToolbarButton
icon={<MdCode size={16} />}
onClick={() => editor.chain().focus().toggleCode().run()}
active={editorState.isCode}
disabled={!editorState.canCode}
label="行内代码"
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<ToolbarButton
label="清除样式"
onClick={() => editor.chain().focus().unsetAllMarks().run()}
/>
<ToolbarButton
label="清除节点"
onClick={() => editor.chain().focus().clearNodes().run()}
title="行内代码"
/>

<div className="w-px h-5 bg-gray-200 mx-1" />

<ToolbarButton
active={editorState.isParagraph}
label="正文"
icon={<MdTextFields size={16} />}
onClick={() => editor.chain().focus().setParagraph().run()}
active={editorState.isParagraph}
title="正文"
/>
<ToolbarButton
active={editorState.isHeading1}
label="H1"
icon={<MdTitle size={16} style={{ fontSize: 18 }} />}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editorState.isHeading1}
title="标题1"
/>
<ToolbarButton
active={editorState.isHeading2}
label="H2"
icon={<MdTitle size={14} />}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editorState.isHeading2}
title="标题2"
/>
<ToolbarButton
active={editorState.isHeading3}
label="H3"
icon={<MdTitle size={12} />}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editorState.isHeading3}
title="标题3"
/>

<div className="w-px h-5 bg-gray-200 mx-1" />

<ToolbarButton
active={editorState.isBulletList}
label="无序列表"
icon={<MdFormatListBulleted size={16} />}
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editorState.isBulletList}
title="无序列表"
/>
<ToolbarButton
active={editorState.isOrderedList}
label="有序列表"
icon={<MdFormatListNumbered size={16} />}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editorState.isOrderedList}
title="有序列表"
/>
<ToolbarButton
active={editorState.isCodeBlock}
label="代码块"
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
/>
<ToolbarButton
active={editorState.isBlockquote}
label="引用"
icon={<MdFormatQuote size={16} />}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editorState.isBlockquote}
title="引用"
/>
<ToolbarButton
label="分割线"
icon={<MdHorizontalRule size={16} />}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="分割线"
/>

<div className="w-px h-5 bg-gray-200 mx-1" />

<ToolbarButton
disabled={!editorState.canUndo}
label="撤销"
icon={<MdUndo size={16} />}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editorState.canUndo}
title="撤销"
/>
<ToolbarButton
disabled={!editorState.canRedo}
label="重做"
icon={<MdRedo size={16} />}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editorState.canRedo}
title="重做"
/>
</div>
);
Expand Down
107 changes: 87 additions & 20 deletions packages/frontend/app/home/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,8 @@ function FolderTreeInline({
else router.push(`/home/cloud-docs/${id}`);
};

const openMoreMenu = (e: React.MouseEvent, id: number, isFolder: boolean) => {
const openMoreMenu = (e: React.MouseEvent, id: number, _isFolder: boolean) => {
e.stopPropagation();
if (!isFolder) return;
setMoreMenuId(moreMenuId === id ? null : id);
setCreatingFolder(false);
setCreatingDoc(false);
Expand All @@ -130,21 +129,35 @@ function FolderTreeInline({
setCreateError(null);
};

// Expand a folder and all its ancestor folders in the tree
const expandFolderAndAncestors = (folderId: number, nodes: TreeNode[], path: number[] = []): number[] => {
for (const node of nodes) {
if (node.id === folderId) return [...path];
if (node.children.length > 0) {
const found = expandFolderAndAncestors(folderId, node.children, [...path, node.id]);
if (found.length > 0) return found;
}
}
return [];
};

const handleCreateItem = async (isFolder: boolean, parentId?: number | null) => {
if (!newItemName.trim()) return;
setCreateError(null);
try {
if (isFolder) {
const body: { title: string; parentFolderId?: number } = { title: newItemName.trim() };
if (parentId && parentId !== -1) body.parentFolderId = parentId;
const res = await fetch("http://localhost:3001/documents/folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ title: newItemName.trim() }),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Failed to create folder");
} else {
const body: { title: string; parentFolderId?: number } = { title: newItemName.trim() };
if (parentId) body.parentFolderId = parentId;
if (parentId && parentId !== -1) body.parentFolderId = parentId;
const res = await fetch("http://localhost:3001/documents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
Expand All @@ -156,18 +169,63 @@ function FolderTreeInline({
setNewItemName("");
setCreatingDoc(false);
setCreatingForFolderId(null);
fetchTree();
// Fetch fresh tree, then expand ancestors using the updated tree
await fetchTree();
if (parentId && parentId !== -1) {
// Use a synchronous tree state read via a separate fetch for accurate ancestors
const treeRes = await fetch("http://localhost:3001/documents/tree", { credentials: "include" });
if (treeRes.ok) {
const currentTree: TreeNode[] = await treeRes.json();
const ancestors = expandFolderAndAncestors(parentId, currentTree);
setExpanded((prev) => {
const next = new Set(prev);
for (const id of ancestors) next.add(id);
next.add(parentId);
return next;
});
}
}
router.push(`/home/cloud-docs/${data.id}`);
return;
}
setNewItemName("");
setCreatingFolder(false);
if (parentId && parentId !== -1) {
const treeRes = await fetch("http://localhost:3001/documents/tree", { credentials: "include" });
if (treeRes.ok) {
const currentTree: TreeNode[] = await treeRes.json();
const ancestors = expandFolderAndAncestors(parentId, currentTree);
setExpanded((prev) => {
const next = new Set(prev);
for (const id of ancestors) next.add(id);
next.add(parentId);
return next;
});
}
}
fetchTree();
} catch {
setCreateError(isFolder ? "Failed to create folder" : "Failed to create document");
}
};

const handleDeleteItem = async (id: number, isFolder: boolean) => {
try {
const endpoint = isFolder
? `http://localhost:3001/documents/folder/${id}`
: `http://localhost:3001/documents/${id}`;
const res = await fetch(endpoint, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete");
closeAll();
fetchTree();
} catch {
// Silently fail
}
};

const startCreateFolder = (forFolderId?: number | null) => {
closeAll();
setCreatingFolder(true);
Expand Down Expand Up @@ -246,26 +304,24 @@ function FolderTreeInline({
{node.title}
</span>

{/* More button — only for folders */}
{node.isFolder && (
<button
onClick={(e) => openMoreMenu(e, node.id, true)}
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-opacity"
style={{ color: "#444653" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
)}
{/* More button — for both folders and documents */}
<button
onClick={(e) => openMoreMenu(e, node.id, !!node.isFolder)}
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-opacity"
style={{ color: "#444653" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
</button>
</div>

{/* More dropdown menu */}
{isMoreOpen && (
<div
className="absolute right-2 top-full z-50 mt-1 py-1 rounded-xl shadow-lg min-w-[120px]"
className="absolute right-2 top-full z-50 mt-1 py-1 rounded-xl shadow-lg min-w-[140px]"
style={{ background: "#ffffff", border: "1px solid rgba(195,198,215,0.3)" }}
onClick={(e) => e.stopPropagation()}
>
Expand All @@ -285,6 +341,17 @@ function FolderTreeInline({
<MdAdd size={14} />
<span>New Document</span>
</button>
<div className="my-1" style={{ borderTop: "1px solid rgba(195,198,215,0.2)" }} />
<button
onClick={() => handleDeleteItem(node.id, node.isFolder)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-red-50 transition-colors"
style={{ color: "#dc2626" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
</svg>
<span>Delete {node.isFolder ? "Folder" : "Document"}</span>
</button>
</div>
)}

Expand Down
Loading