0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
@@ -35,12 +35,20 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
showStructure: doc.show_structure ?? false,
protectEditing: doc.protect_editing ?? false,
showWordCount: doc.show_word_count ?? true,
collapseBacklinks: (doc as any).collapse_backlinks ?? false,
pageFont: (doc as any).page_font ?? "default",
layoutDensity: (doc as any).layout_density ?? "normal",
hideChildPages: (doc as any).hide_child_pages ?? false,
showBlockRefCount: (doc as any).show_block_ref_count ?? false,
embedDefaultBlockId: (doc as any).embed_default_block_id ?? null,
};
const initialStats: DocumentStats = {
wordCount: doc.word_count ?? 0,
characterCount: doc.character_count ?? 0,
blockCount: doc.block_count ?? 0,
todoTotal: (doc as any).todo_total ?? (doc as any).todo_total_count ?? 0,
todoDone: (doc as any).todo_done ?? (doc as any).todo_done_count ?? 0,
};
return (
+66 -2
View File
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { Sidebar } from "@/components/sidebar/sidebar";
import { Breadcrumb } from "@/components/breadcrumb";
import { BottomToolbar } from "@/components/bottom-toolbar";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import type { DocumentRecord } from "@/lib/documents";
import type { SidebarInitialData } from "@/components/sidebar/types";
@@ -154,6 +153,70 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
};
});
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
userId: auth.userId,
workspaceId: activeWorkspaceId,
includeArchived: true,
limit: 3000,
});
const tableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => !row.is_archived)
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? activeWorkspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
const trashedTableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => Boolean(row.is_archived))
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? activeWorkspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
deleted_at: row.deleted_at ?? row.updated_at ?? null,
deleted_by: row.deleted_by ?? null,
purged_at: row.purged_at ?? null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
sidebarInitialData = {
activeWorkspaceId,
workspaces,
@@ -161,10 +224,12 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
trashedMediaAssets: [],
trashedMindmapAssets,
trashedTableAssets,
mediaAssets: [],
mindmapDocs,
mindmapAssets,
mindmapAssetChildren,
tableAssets,
};
}
@@ -177,7 +242,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
<Breadcrumb documents={documents} />
</header>
<main className="flex-1 overflow-hidden bg-white">{children}</main>
<BottomToolbar />
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
</div>
</div>
@@ -34,8 +34,15 @@ export async function POST(request: Request) {
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
const targetBlocks = getBlocksFromDocumentContent(target.content);
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
const referenceBlock = {
id: randomUUID(),
type: "blockReference",
@@ -48,7 +55,7 @@ export async function POST(request: Request) {
children: [],
};
const nextBlocks = [...targetBlocks, referenceBlock];
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
const payload = withBlocksWrittenBack(target.content, nextBlocks);
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
@@ -30,10 +30,18 @@ export async function POST(request: Request) {
if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
const currentBlocks = extractBlocksFromContent(targetContent.content);
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
const anchorIndex =
typeof anchorId === "string" && anchorId.trim()
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
: -1;
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
const nextBlocks: Json[] = [
...currentBlocks,
...currentBlocks.slice(0, insertIndex),
{
id: randomUUID(),
type: "pageReference",
@@ -42,6 +50,7 @@ export async function POST(request: Request) {
title: sourceDoc.title ?? "无标题",
},
},
...currentBlocks.slice(insertIndex),
];
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
showStructure: options.showStructure,
protectEditing: options.protectEditing,
showWordCount: options.showWordCount,
collapseBacklinks: options.collapseBacklinks,
pageFont: options.pageFont,
layoutDensity: options.layoutDensity,
hideChildPages: options.hideChildPages,
showBlockRefCount: options.showBlockRefCount,
embedDefaultBlockId: typeof options.embedDefaultBlockId === "string" ? options.embedDefaultBlockId : null,
},
});
@@ -22,6 +22,8 @@ export async function POST(request: Request) {
wordCount: stats.wordCount,
characterCount: stats.characterCount,
blockCount: stats.blockCount,
todoTotal: stats.todoTotal,
todoDone: stats.todoDone,
});
return NextResponse.json({ ok: true });
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
type TemplatePayload = {
documentId: string;
isTemplate: boolean;
};
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { documentId, isTemplate }: TemplatePayload = await request.json();
if (!documentId || typeof isTemplate !== "boolean") {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -4,6 +4,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
@@ -37,7 +38,13 @@ export async function GET(request: Request) {
limit: Number.isNaN(limit) ? 12 : limit,
});
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
const safeItems = (items ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
})) as MediaAsset[];
return NextResponse.json({ items: safeItems });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -149,7 +156,13 @@ export async function POST(request: Request) {
},
});
return NextResponse.json({ asset });
const safeAsset = {
...asset,
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
} as MediaAsset;
return NextResponse.json({ asset: safeAsset });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -6,6 +6,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
@@ -200,8 +201,14 @@ export async function POST(request: Request) {
}
}
return NextResponse.json({ items: results });
}
const safeItems = (results ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
}));
return NextResponse.json({ items: safeItems });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
@@ -4,6 +4,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
@@ -34,9 +35,14 @@ export async function GET(request: Request) {
limit: Number.isNaN(limit) ? 200 : limit,
});
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
const safeItems = (items ?? []).map((a: any) => ({
...a,
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
})) as MediaAsset[];
return NextResponse.json({ items: safeItems });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -10,21 +10,10 @@ interface EmptyTrashPayload {
workspaceId?: string;
}
function resolveGraceSeconds(): number {
const raw =
process.env.DELETE_GRACE_SECONDS ??
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
"600";
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 600;
}
return Math.floor(parsed);
}
function makeExpiredDeletedAt(): string {
const graceSeconds = resolveGraceSeconds();
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
function makeExpiredDeletedAtForForceEmpty(): string {
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的附件,不受 10 分钟宽限期限制。
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
}
export async function POST(request: Request) {
@@ -48,7 +37,7 @@ export async function POST(request: Request) {
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
expiredDeletedAt: makeExpiredDeletedAt(),
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
});
return NextResponse.json({ success: true, ...(res as any) });
+2 -36
View File
@@ -3,44 +3,10 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const getRequestOrigin = (request: Request) => {
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
return `${protocol}//${host}`;
};
const isLocalHostname = (hostname: string) =>
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
const input = String(rawUrl || "").trim();
if (!input) return input;
if (input.startsWith("/api/onlyoffice/proxy")) return input;
try {
const u = new URL(input);
if (!isLocalHostname(u.hostname)) return input;
const origin = getRequestOrigin(request);
const proxy = new URL("/api/onlyoffice/proxy", origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
return proxy.toString();
} catch {
return input;
}
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
@@ -75,7 +41,7 @@ export async function GET(request: Request) {
}
return NextResponse.json({
signedUrl: maybeProxyForBrowser(request, signedUrl),
signedUrl: maybeProxyForBrowserUrl(request, signedUrl),
asset: {
id: asset.id,
file_name: asset.file_name,
@@ -1,44 +1,10 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const getRequestOrigin = (request: Request) => {
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
return `${protocol}//${host}`;
};
const isLocalHostname = (hostname: string) =>
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
const input = String(rawUrl || "").trim();
if (!input) return input;
if (input.startsWith("/api/onlyoffice/proxy")) return input;
try {
const u = new URL(input);
if (!isLocalHostname(u.hostname)) return input;
const origin = getRequestOrigin(request);
const proxy = new URL("/api/onlyoffice/proxy", origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
return proxy.toString();
} catch {
return input;
}
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
try {
@@ -58,7 +24,7 @@ export async function GET(request: Request) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
return NextResponse.json({ signedUrl: maybeProxyForBrowser(request, fileUrl) });
return NextResponse.json({ signedUrl: maybeProxyForBrowserUrl(request, fileUrl) });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
@@ -7,6 +7,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
export const dynamic = "force-dynamic";
@@ -89,9 +90,14 @@ export async function POST(request: Request) {
});
const asset = created as unknown as MediaAsset;
const safeAsset = {
...(asset as any),
file_url: maybeProxyForBrowserUrl(request, String((asset as any)?.file_url ?? "")),
thumbnail_url: maybeProxyForBrowserUrl(request, String((asset as any)?.thumbnail_url ?? (asset as any)?.file_url ?? "")),
} as MediaAsset;
return NextResponse.json({
asset,
asset: safeAsset,
mindmapUrl: `asset:${asset.id}`,
});
} catch (error) {
@@ -3,6 +3,11 @@ import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
import type { MediaAsset } from "@/types/media";
import path from "path";
import { promises as fs } from "fs";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
import { HttpError } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
@@ -39,7 +44,89 @@ async function listTestPdfs(): Promise<AgentAssetItem[]> {
}
export async function GET(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { searchParams } = new URL(request.url);
const documentId = String(searchParams.get("documentId") ?? "").trim();
const q = String(searchParams.get("q") ?? "").trim().toLowerCase();
const workspaceOnly = String(searchParams.get("workspaceOnly") ?? "").trim() === "1";
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
let auth: { userId: string };
let client: any;
try {
const res = await getAuthedConvexClient();
auth = res.auth;
client = res.client;
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const doc = await client.query(api.documents.getMeta, { id: documentId });
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
if (workspaceOnly) {
return NextResponse.json({
ok: true,
workspaceId: (doc as any).workspace_id ?? null,
documentId,
items: [],
});
}
// 说明:这里的附件列表主要用于 Mindmap AI Agent 选择引用素材。
const mediaRows = (await client.query(api.mediaAssets.listByDocument, {
userId: auth.userId,
documentId,
limit: 200,
})) as unknown as MediaAsset[];
const mediaAssets: AgentAssetItem[] = (mediaRows ?? []).map((row) => ({
kind: "media" as const,
id: String((row as any).id ?? ""),
title: String((row as any).file_name ?? (row as any).id ?? "附件"),
fileUrl: maybeProxyForBrowserUrl(request, String((row as any).file_url ?? "")),
mimeType: (row as any).mime_type ?? null,
assetType: (row as any).asset_type ?? null,
fileName: (row as any).file_name ?? null,
}));
const localMindmaps = (await detectLocalMindmapFiles([documentId]))
.filter((x) => x.documentId === documentId)
.map((x) => ({
kind: "local-mindmap" as const,
id: `mindmap:${x.mindmapId}`,
title: x.fileName,
fileUrl: `/documents/${documentId}/${x.fileName}`,
mimeType: "application/json",
assetType: "mindmap",
fileName: x.fileName,
}));
const testPdfs = await listTestPdfs();
let items: AgentAssetItem[] = [...localMindmaps, ...mediaAssets, ...testPdfs];
if (q) {
items = items.filter((it) => {
const hay = `${it.title} ${it.fileName ?? ""}`.toLowerCase();
return hay.includes(q);
});
}
return NextResponse.json({
ok: true,
workspaceId: (doc as any).workspace_id ?? null,
documentId,
items,
});
/*
const supabase = await createSupabaseRouteClient();
+66 -1
View File
@@ -109,6 +109,13 @@ export async function GET(request: Request) {
limit: 2000,
});
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
userId: auth.userId,
workspaceId: targetWorkspaceId,
includeArchived: true,
limit: 3000,
});
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
@@ -173,6 +180,63 @@ export async function GET(request: Request) {
};
});
const tableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => !row.is_archived)
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? targetWorkspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
const trashedTableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => Boolean(row.is_archived))
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? targetWorkspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
deleted_at: row.deleted_at ?? row.updated_at ?? null,
deleted_by: row.deleted_by ?? null,
purged_at: row.purged_at ?? null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
const payload: SidebarInitialData = {
activeWorkspaceId: targetWorkspaceId,
workspaces,
@@ -180,10 +244,11 @@ export async function GET(request: Request) {
trashedDocuments,
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
trashedMindmapAssets,
trashedTableAssets,
mindmapDocs,
mindmapAssets,
mindmapAssetChildren,
tableAssets: [],
tableAssets,
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
};
@@ -103,7 +103,8 @@ export async function DELETE(_request: Request, context: RouteContext) {
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
await client.mutation(api.tables.purge, {
// 软删除:进入垃圾桶(可恢复)
await client.mutation(api.tables.remove, {
userId: auth.userId,
tableId,
});
@@ -117,4 +118,3 @@ export async function DELETE(_request: Request, context: RouteContext) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
interface EmptyTrashPayload {
workspaceId?: string;
}
function makeExpiredDeletedAtForForceEmpty(): string {
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的表格,不受 10 分钟宽限期限制。
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
}
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
let auth;
try {
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = await getConvexAuthedHttpClient();
try {
const res = await client.mutation(api.tables.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
});
return NextResponse.json({ success: true, ...(res as any) });
} catch (err) {
const message = err instanceof Error ? err.message : "清空失败";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
if (!tableId) {
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
try {
await client.mutation(api.tables.purge, { userId: auth.userId, tableId });
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
if (!tableId) {
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
try {
await client.mutation(api.tables.restore, { userId: auth.userId, tableId });
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
@@ -0,0 +1,133 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
import { cn } from "@/lib/utils";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{ ssr: false },
);
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = {
wordCount: 0,
characterCount: 0,
blockCount: 0,
todoTotal: 0,
todoDone: 0,
};
export default function PageOptionsPlaygroundPage() {
const editorBridge = useEditorBridgeStore((s) => s.bridge);
const [options, setOptions] = useState<PageOptionsState>(defaultOptions);
const [stats, setStats] = useState<DocumentStats>(defaultStats);
const documentId = "dev-page-options";
const workspaceId = "dev-workspace";
const initialContent = useMemo(
() => [
{
id: "h1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "标题一" }],
},
{
id: "p1",
type: "paragraph",
props: {},
content: [{ type: "text", text: "这是用于回归测试页面选项的示例段落。" }],
},
],
[],
);
const toggleOption = useCallback((key: BooleanPageOptionKey) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const setPageFont = useCallback((font: PageFont) => setOptions((prev) => ({ ...prev, pageFont: font })), []);
const setLayoutDensity = useCallback(
(density: PageLayoutDensity) => setOptions((prev) => ({ ...prev, layoutDensity: density })),
[],
);
const pageRootClass = cn(
"flex h-[calc(100vh-64px)] overflow-hidden bg-wolai-bg",
options.pageFont === "song" && "wolai-page-font-song",
options.pageFont === "kai" && "wolai-page-font-kai",
options.layoutDensity === "compact" && "wolai-page-density-compact",
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
return (
<div className="p-6">
<div className="mb-4 text-sm text-gray-500">
Dev Playground Playwright
</div>
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className={pageRootClass}>
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="text-3xl font-semibold text-wolai-text-primary"></div>
<p className="mt-1 text-sm text-wolai-text-secondary"></p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent as unknown}
pageOptions={options}
readOnly={false}
onStatsChange={setStats}
/>
</div>
</div>
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
onSetPageFont={setPageFont}
onSetLayoutDensity={setLayoutDensity}
onSetEmbedDefaultToCursor={() => window.alert("该页面为 Dev Playground,不写入后端")}
onClearEmbedDefault={() => setOptions((prev) => ({ ...prev, embedDefaultBlockId: null }))}
onExport={() => window.alert("该页面为 Dev Playground,不提供导出")}
onOpenHistory={() => window.alert("该页面为 Dev Playground,不提供历史")}
onOpenComments={() => window.alert("该页面为 Dev Playground,不提供评论")}
onUndo={() => editorBridge?.undo?.()}
onRedo={() => editorBridge?.redo?.()}
onDeletePage={() => window.alert("该页面为 Dev Playground,不提供删除")}
onOpenMoveEmbedPicker={() => window.alert("该页面为 Dev Playground,不提供移动/嵌入")}
onCopyPageLink={() => window.alert("该页面为 Dev Playground,不提供复制链接")}
onCopyPageReference={() => window.alert("该页面为 Dev Playground,不提供引用")}
onAddToTemplates={() => window.alert("该页面为 Dev Playground,不提供模板")}
/>
</div>
</ImagePickerProvider>
</div>
);
}
+140 -21
View File
@@ -207,52 +207,72 @@ body {
.wolai-editor .bn-block-group:hover .bn-drag-handle {
opacity: 1;
}
.wolai-editor[data-heading-numbering="true"] {
.wolai-editor.wolai-heading-numbering {
counter-reset: wolai-h1 wolai-h2 wolai-h3 wolai-h4 wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h1 {
/* 说明:BlockNote 的 heading 主要由 .bn-block-content[data-content-type=heading] 承载。
为避免不同渲染结构导致“标题编号”失效,这里以块级容器为准实现编号。 */
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level]),
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"] {
counter-increment: wolai-h1;
counter-reset: wolai-h2;
}
.wolai-editor[data-heading-numbering="true"] h1::before {
content: counter(wolai-h1) ". ";
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level])::before,
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"]::before {
content: counter(wolai-h1) ". " !important;
color: #94a3b8;
margin-right: 8px;
margin-right: 8px !important;
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
}
.wolai-editor[data-heading-numbering="true"] h2 {
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"] {
counter-increment: wolai-h2;
counter-reset: wolai-h3;
}
.wolai-editor[data-heading-numbering="true"] h2::before {
content: counter(wolai-h1) "." counter(wolai-h2) ". ";
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"]::before {
content: counter(wolai-h1) "." counter(wolai-h2) ". " !important;
color: #94a3b8;
margin-right: 6px;
margin-right: 6px !important;
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
}
.wolai-editor[data-heading-numbering="true"] h3 {
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"] {
counter-increment: wolai-h3;
counter-reset: wolai-h4;
}
.wolai-editor[data-heading-numbering="true"] h3::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". ";
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"]::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". " !important;
color: #cbd5f5;
margin-right: 4px;
margin-right: 4px !important;
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
}
.wolai-editor[data-heading-numbering="true"] h4 {
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"] {
counter-increment: wolai-h4;
counter-reset: wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h4::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". ";
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"]::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". " !important;
color: #d0d7e7;
margin-right: 4px;
margin-right: 4px !important;
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
}
.wolai-editor[data-heading-numbering="true"] h5 {
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"] {
counter-increment: wolai-h5;
}
.wolai-editor[data-heading-numbering="true"] h5::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". ";
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"]::before {
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". " !important;
color: #d4d4d8;
margin-right: 4px;
margin-right: 4px !important;
display: inline-flex;
align-items: baseline;
flex: 0 0 auto;
}
.wolai-editor-show-structure .bn-block-outer {
outline: 1px dashed #d4d4d8;
@@ -587,6 +607,105 @@ body {
line-height: 1.6;
}
/* 页面选项:小字体(覆盖 BlockNote 的 .bn-default-styles 默认字号) */
.wolai-small-text .wolai-editor.bn-default-styles,
.wolai-small-text .wolai-editor .bn-default-styles {
font-size: 15px;
}
/* 自定义页面:字体 */
.wolai-page-font-song {
font-family: "宋体", SimSun, "Songti SC", serif;
}
.wolai-page-font-song .wolai-editor.bn-default-styles,
.wolai-page-font-song .wolai-editor .bn-default-styles {
font-family: "宋体", SimSun, "Songti SC", serif;
}
.wolai-page-font-kai {
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
}
.wolai-page-font-kai .wolai-editor.bn-default-styles,
.wolai-page-font-kai .wolai-editor .bn-default-styles {
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
}
/* 自定义页面:布局密度(紧凑/默认/宽容) */
.wolai-page-density-compact .wolai-editor .bn-block-content {
line-height: 1.45;
padding-top: 1px;
padding-bottom: 1px;
}
.wolai-page-density-spacious .wolai-editor .bn-block-content {
line-height: 1.75;
padding-top: 6px;
padding-bottom: 6px;
}
/* 页面选项:小字体(强制覆盖 BlockNote 默认字号) */
.wolai-small-text .wolai-editor.bn-default-styles,
.wolai-small-text .wolai-editor .bn-default-styles,
.wolai-editor.wolai-small-text.bn-default-styles,
.wolai-editor.wolai-small-text .bn-default-styles {
font-size: 15px !important;
}
/* 自定义页面:字体(强制覆盖 BlockNote 默认字体) */
.wolai-page-font-song .wolai-editor.bn-default-styles,
.wolai-page-font-song .wolai-editor .bn-default-styles,
.wolai-editor.wolai-page-font-song.bn-default-styles,
.wolai-editor.wolai-page-font-song .bn-default-styles {
font-family: "宋体", SimSun, "Songti SC", serif !important;
}
.wolai-page-font-kai .wolai-editor.bn-default-styles,
.wolai-page-font-kai .wolai-editor .bn-default-styles,
.wolai-editor.wolai-page-font-kai.bn-default-styles,
.wolai-editor.wolai-page-font-kai .bn-default-styles {
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif !important;
}
/* 自定义页面:布局密度(强制覆盖 BlockNote 默认行高/间距) */
.wolai-page-density-compact .wolai-editor .bn-block-content,
.wolai-editor.wolai-page-density-compact .bn-block-content {
line-height: 1.45 !important;
padding-top: 1px !important;
padding-bottom: 1px !important;
}
.wolai-page-density-spacious .wolai-editor .bn-block-content,
.wolai-editor.wolai-page-density-spacious .bn-block-content {
line-height: 1.75 !important;
padding-top: 6px !important;
padding-bottom: 6px !important;
}
.wolai-page-density-compact .wolai-editor h1 {
margin-top: 1.2em;
}
.wolai-page-density-spacious .wolai-editor h1 {
margin-top: 1.8em;
}
.wolai-page-density-compact .wolai-editor h2,
.wolai-page-density-compact .wolai-editor h3 {
margin-top: 1.1em;
}
.wolai-page-density-spacious .wolai-editor h2,
.wolai-page-density-spacious .wolai-editor h3 {
margin-top: 1.6em;
}
/* 自定义页面:隐藏子页面(仅隐藏通过 /ym 创建的子页面块) */
.wolai-hide-child-pages [data-child-page="true"] {
display: none !important;
}
/* 标题样式 */
.wolai-editor h1 {
font-size: 30px;
+2
View File
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider";
import { AppPreferencesHydrator } from "@/components/providers/app-preferences-hydrator";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
@@ -42,6 +43,7 @@ export default async function RootLayout({
<body className={`${inter.variable} antialiased`}>
<ConvexAuthNextjsServerProvider>
<ConvexClientProvider>
<AppPreferencesHydrator />
<QueryProvider>{children}</QueryProvider>
</ConvexClientProvider>
</ConvexAuthNextjsServerProvider>
@@ -375,6 +375,12 @@ export default function OnlyOfficePage() {
if (!uniq.includes(s)) uniq.push(s);
};
const isPageLocal = (() => {
if (typeof window === "undefined") return true;
const host = window.location.hostname;
return host === "127.0.0.1" || host === "localhost";
})();
// 说明:网页端优先走同源 /onlyoffice-serverNext 代理到 ONLYOFFICE_INTERNAL_URL),
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
@@ -390,7 +396,10 @@ export default function OnlyOfficePage() {
if (channel === "web") {
push(runtimeConfig.onlyofficeBaseUrlWeb);
push(runtimeConfig.onlyofficeBaseUrl);
push(runtimeConfig.onlyofficeBaseUrlDesktop);
// 说明:Web 端通过公网访问时,不应回退到 localhost/127(会触发浏览器“本地网络”权限提示,且必然不可达)。
if (isPageLocal) {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
}
return uniq;
}
if (channel === "desktop") {
@@ -404,7 +413,9 @@ export default function OnlyOfficePage() {
if (runtimeConfig.isDesktop) {
push(runtimeConfig.onlyofficeBaseUrlWeb);
} else {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
if (isPageLocal) {
push(runtimeConfig.onlyofficeBaseUrlDesktop);
}
}
return uniq;
}, [channel, runtimeConfig]);
+36 -1
View File
@@ -2,11 +2,14 @@
import Link from "next/link";
import { useParams, useSelectedLayoutSegments } from "next/navigation";
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
import { ChevronRight, MoreHorizontal, Sparkles, Star } from "lucide-react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import type { DocumentRecord } from "@/lib/documents";
import { findBreadcrumb } from "@/lib/documents";
import { useBackendHealth } from "@/hooks/use-backend-health";
import { cn } from "@/lib/utils";
import { usePageLayoutStore } from "@/store/page-layout";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { api } from "@/lib/convex/api";
interface BreadcrumbProps {
@@ -22,6 +25,9 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
const path = findBreadcrumb(documents, activeId);
const showInspector = usePageLayoutStore((state) => state.showInspector);
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
const backendStatus = useBackendHealth();
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
const isStarred = useQuery(
api.documentStars.isStarred,
activeId && isAuthenticated ? { documentId: activeId } : "skip",
@@ -60,6 +66,35 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
})}
</nav>
<div className="flex items-center gap-2 text-wolai-text-secondary">
<span
className={cn(
"h-2 w-2 rounded-full",
backendStatus === "ok" ? "bg-green-500" : "bg-red-500",
)}
title={`后端连接:${
backendStatus === "ok"
? "正常"
: backendStatus === "disabled"
? "未配置"
: backendStatus === "error"
? "异常"
: "检测中"
}`}
aria-label="后端连接状态"
/>
<button
type="button"
className={cn(
"hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors",
!documentAgentAvailable && "cursor-not-allowed opacity-50",
)}
onClick={() => toggleDocumentAgentOpen()}
disabled={!documentAgentAvailable}
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
>
<Sparkles className="mr-1 inline h-4 w-4" />
AI
</button>
<button
type="button"
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
import type { Json } from "@/types/supabase";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -154,10 +155,11 @@ export function DocumentAiAgentPanel({
const open = useAiAgentUiStore((s) => s.documentAgentOpen);
const setOpen = useAiAgentUiStore((s) => s.setDocumentAgentOpen);
const setAvailable = useAiAgentUiStore((s) => s.setDocumentAgentAvailable);
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [toolPickerOpen, setToolPickerOpen] = useState(false);
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
@@ -179,6 +181,12 @@ export function DocumentAiAgentPanel({
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
setAvailable(true);
return () => {
@@ -29,7 +29,12 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
import { ASSETS_CHANGED_EVENT, ASSETS_RESTORED_EVENT, emitAssetsChanged } from "@/lib/events";
import { deleteOnlineTable } from "@/lib/online-table";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
interface BlockNoteEditorProps {
documentId: string;
@@ -39,6 +44,7 @@ interface BlockNoteEditorProps {
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
@@ -172,6 +178,7 @@ export function BlockNoteEditor({
readOnly = false,
onStatsChange,
onSnapshot,
onCloseToc,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
@@ -180,6 +187,26 @@ export function BlockNoteEditor({
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const showStructure = useAppPreferencesStore((s) => s.showStructure);
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const { isAuthenticated } = useConvexAuth();
const threads = useQuery(
api.comments.listThreadsByDocument,
isAuthenticated && documentId ? { documentId, includeResolved: false } : "skip",
);
const unresolvedCommentCountByBlockId = useMemo(() => {
const map: Record<string, number> = {};
if (!Array.isArray(threads)) return map;
for (const t of threads as any[]) {
const bid = String(t?.blockId ?? "");
if (!bid) continue;
map[bid] = (map[bid] ?? 0) + 1;
}
return map;
}, [threads]);
const normalizedInitialContent = useMemo(
() => extractInitialBlocks(initialContent),
@@ -202,6 +229,10 @@ export function BlockNoteEditor({
{
initialContent: normalizedInitialContent as never,
schema: customBlockSchema,
placeholders: {
default: "输入'/'选择,按 空格 打开AI...",
emptyDocument: "输入'/'选择,按 空格 打开AI...",
},
collaboration: collaboration
? {
provider: collaboration.provider,
@@ -243,6 +274,11 @@ export function BlockNoteEditor({
const debouncedSave = useDebouncedCallback(saveContent, 800);
const previousAssetsRef = useRef<Set<string>>(new Set());
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
const previousOnlineTableIdsRef = useRef<Set<string>>(new Set());
const onlineTableDeleteTimestampsRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoreRetryCountRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoreRetryTimerRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoringRef = useRef<Set<string>>(new Set());
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
@@ -290,6 +326,7 @@ export function BlockNoteEditor({
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
const mindmapBlockIds = new Set<string>();
const onlineTableIds = new Set<string>();
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
@@ -299,13 +336,17 @@ export function BlockNoteEditor({
if (b.type === "mindmap") {
mindmapBlockIds.add(b.id);
}
if (b.type === "onlineTable") {
const id = (b.props as { tableId?: string })?.tableId;
if (id) onlineTableIds.add(id);
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return { assetIds, mindmapBlockIds };
return { assetIds, mindmapBlockIds, onlineTableIds };
}, []);
const deleteAssets = useCallback(
@@ -331,6 +372,15 @@ export function BlockNoteEditor({
const deleteMindmapAssets = useCallback(
async (mindmapIds: string[]) => {
if (mindmapIds.length === 0) return;
// 关键:当用户在编辑器里“直接删除 mindmap 块”(例如 Backspace/原生删除)时,
// React 会先卸载 MindmapBlock;若此时未及时标记“删除中”,MindmapBlock 的卸载清理会
// 把最后一次数据 POST 回 /api/mindmap/...,导致侧边栏的 mindmap 文件看起来没有被同步删除。
// 因此这里必须在发起 DELETE 前先标记并清理 autosave,确保卸载清理跳过持久化写回。
const unique = Array.from(new Set(mindmapIds)).filter((id) => typeof id === "string" && id);
unique.forEach((mindmapId) => {
markMindmapDeleting(documentId, mindmapId);
clearMindmapAutosaveCache(documentId, mindmapId);
});
await Promise.all(
mindmapIds.map(async (mindmapId) => {
try {
@@ -351,6 +401,115 @@ export function BlockNoteEditor({
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
);
const deleteOnlineTables = useCallback(
async (tableIds: string[]) => {
const unique = Array.from(new Set(tableIds)).filter((id) => typeof id === "string" && id);
if (unique.length === 0) return;
await Promise.all(
unique.map(async (tableId) => {
try {
await deleteOnlineTable(tableId);
// 通知侧边栏/其它视图:立即从文件树移除,并触发订阅更新
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
emitAssetsChanged(documentId);
}
} catch (error) {
console.error("删除在线表格失败", tableId, error);
}
}),
);
},
[documentId],
);
useEffect(() => {
return () => {
// 清理 restore 重试计时器,避免页面卸载后继续触发网络请求
onlineTableRestoreRetryTimerRef.current.forEach((timerId) => {
try {
window.clearTimeout(timerId);
} catch {
// ignore
}
});
onlineTableRestoreRetryTimerRef.current.clear();
onlineTableRestoreRetryCountRef.current.clear();
onlineTableRestoringRef.current.clear();
};
}, []);
const restoreOnlineTableIfNeeded = useCallback(
async (tableId: string) => {
const ts = onlineTableDeleteTimestampsRef.current.get(tableId);
if (!ts) return;
// 仅对“最近删除”的表格做恢复(用于 Ctrl+Z / Undo),避免首次加载时误触发 restore
if (Date.now() - ts > 10 * 60 * 1000) {
onlineTableDeleteTimestampsRef.current.delete(tableId);
onlineTableRestoreRetryCountRef.current.delete(tableId);
const pendingTimer = onlineTableRestoreRetryTimerRef.current.get(tableId);
if (pendingTimer) {
try {
window.clearTimeout(pendingTimer);
} catch {
// ignore
}
onlineTableRestoreRetryTimerRef.current.delete(tableId);
}
return;
}
if (onlineTableRestoringRef.current.has(tableId)) {
return;
}
onlineTableRestoringRef.current.add(tableId);
try {
const resp = await fetch("/api/tables/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!resp.ok) {
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
const next = prev + 1;
onlineTableRestoreRetryCountRef.current.set(tableId, next);
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
const timerId = window.setTimeout(() => {
onlineTableRestoreRetryTimerRef.current.delete(tableId);
void restoreOnlineTableIfNeeded(tableId);
}, 600 * next);
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
}
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
emitAssetsChanged(documentId);
}
onlineTableDeleteTimestampsRef.current.delete(tableId);
onlineTableRestoreRetryCountRef.current.delete(tableId);
} catch (error) {
console.error("恢复在线表格失败", tableId, error);
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
const next = prev + 1;
onlineTableRestoreRetryCountRef.current.set(tableId, next);
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
const timerId = window.setTimeout(() => {
onlineTableRestoreRetryTimerRef.current.delete(tableId);
void restoreOnlineTableIfNeeded(tableId);
}, 600 * next);
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
}
} finally {
onlineTableRestoringRef.current.delete(tableId);
}
},
[documentId],
);
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
const handler = (event: Event) => {
@@ -419,6 +578,48 @@ export function BlockNoteEditor({
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
// 监听在线表格删除事件(文件树/其它页面触发),主动移除编辑区的 onlineTable 块,并关闭全屏窗口
useEffect(() => {
if (!editor) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (!tableId) return;
onlineTableDeleteTimestampsRef.current.set(tableId, Date.now());
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "onlineTable") {
const id = (b.props as { tableId?: string })?.tableId;
if (id && id === tableId) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
try {
editor.removeBlocks(toRemove);
} catch {
// ignore
}
}
setFullScreenTableId((prev) => (prev === tableId ? null : prev));
};
window.addEventListener("online-table-deleted", handler as EventListener);
return () => window.removeEventListener("online-table-deleted", handler as EventListener);
}, [editor]);
useEffect(() => {
if (!editor) {
return undefined;
@@ -439,7 +640,7 @@ export function BlockNoteEditor({
onSnapshot?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks);
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
const prevAssets = previousAssetsRef.current;
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
if (removedAssets.length > 0) {
@@ -452,6 +653,19 @@ export function BlockNoteEditor({
void deleteMindmapAssets(removedMindmaps);
}
previousMindmapBlockIdsRef.current = mindmapBlockIds;
const prevTables = previousOnlineTableIdsRef.current;
const removedTables = [...prevTables].filter((id) => !onlineTableIds.has(id));
if (removedTables.length > 0) {
removedTables.forEach((id) => onlineTableDeleteTimestampsRef.current.set(id, Date.now()));
void deleteOnlineTables(removedTables);
}
const addedTables = [...onlineTableIds].filter((id) => !prevTables.has(id));
if (addedTables.length > 0) {
addedTables.forEach((id) => void restoreOnlineTableIfNeeded(id));
}
previousOnlineTableIdsRef.current = onlineTableIds;
};
runSync();
@@ -462,7 +676,7 @@ export function BlockNoteEditor({
disposed = true;
unsubscribe?.();
};
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]);
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -478,7 +692,8 @@ export function BlockNoteEditor({
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
(showStructure || pageOptions.showStructure) && "wolai-editor-show-structure",
pageOptions.showHeadingNumbers && "wolai-heading-numbering",
isFullScreenTableOpen && "pointer-events-none select-none",
);
@@ -523,6 +738,8 @@ const generateBlockId = () => {
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
let characterCount = 0;
let wordCount = 0;
let todoTotal = 0;
let todoDone = 0;
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
@@ -543,6 +760,24 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
}
});
}
// 待办统计:
// - advancedTodo:取消不计入总数;done 计为完成
// - checkListItemBlockNote 默认块):按 checked 统计
if (block.type === "advancedTodo") {
const status = String((block.props as any)?.status ?? "todo");
if (status !== "cancelled") {
todoTotal += 1;
if (status === "done") {
todoDone += 1;
}
}
} else if (block.type === "checkListItem") {
todoTotal += 1;
if (Boolean((block.props as any)?.checked)) {
todoDone += 1;
}
}
if (block.children && block.children.length > 0) {
accumulate(block.children as Block<CustomBlockSchema>[]);
}
@@ -553,6 +788,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
wordCount,
characterCount,
blockCount: blocks.length,
todoTotal,
todoDone,
};
};
@@ -561,15 +798,32 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!editor) {
return;
}
const topBlocks = editor.topLevelBlocks as any[];
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
const exists = topBlocks.some((b) => {
if (b.type !== "media") return false;
const id = (b.props as { assetId?: string })?.assetId;
return Boolean(id && asset.id && String(id) === String(asset.id));
});
if (exists) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
return;
try {
// 兜底:部分情况下(例如删除掉最后一个块)topLevelBlocks 可能为空,先补一个段落作为插入锚点
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
@@ -595,6 +849,151 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, editor],
);
const insertMindmapBlock = useCallback(
(args: { documentId: string; mindmapId: string }) => {
if (!editor) return;
// 仅允许插入到当前打开的页面
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
const mindmapId = String(args.mindmapId ?? "").trim();
if (!mindmapId) return;
const topBlocks = editor.topLevelBlocks as any[];
const exists = topBlocks.some((b) => b.type === "mindmap" && String(b.id) === mindmapId);
if (exists) return;
const cursor = editor.getTextCursorPosition();
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
id: mindmapId,
type: "mindmap",
props: { docId: documentId },
content: [],
} as any,
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
const insertOnlineTableBlock = useCallback(
(args: { documentId: string; tableId: string }) => {
if (!editor) return;
// 仅允许插入到当前打开的页面
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
const tableId = String(args.tableId ?? "").trim();
if (!tableId) return;
const topBlocks = editor.topLevelBlocks as any[];
const exists = topBlocks.some((b) => {
if (b.type !== "onlineTable") return false;
const id = (b.props as { tableId?: string })?.tableId;
return Boolean(id && String(id) === tableId);
});
if (exists) return;
const cursor = editor.getTextCursorPosition();
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
type: "onlineTable",
props: { tableId, title: "未命名表格" },
content: [],
} as any,
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
useEffect(() => {
if (!editor) return undefined;
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
| { docId?: string; kind?: "mindmap"; mindmapId?: string }
| { docId?: string; kind?: "table"; tableId?: string };
if (!detail || !detail.docId) return;
if (String(detail.docId) !== String(documentId)) return;
if ((detail as any).kind === "mindmap") {
const mindmapId = String((detail as any).mindmapId ?? "").trim();
if (!mindmapId) return;
insertMindmapBlock({ documentId: detail.docId, mindmapId });
return;
}
if ((detail as any).kind === "table") {
const tableId = String((detail as any).tableId ?? "").trim();
if (!tableId) return;
insertOnlineTableBlock({ documentId: detail.docId, tableId });
return;
}
if ((detail as any).kind === "media") {
const assetId = String((detail as any).assetId ?? "").trim();
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
if (!assetId) return;
void (async () => {
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
let fileUrl = (asset?.file_url ?? "").trim();
if (!fileUrl) {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
if (res.ok) {
const payload = (await res.json().catch(() => null)) as any;
fileUrl = String(payload?.signedUrl ?? "").trim();
}
} catch {
// ignore
}
}
if (!fileUrl) return;
insertMediaAssetBlock({
...(asset ?? ({} as MediaAsset)),
id: assetId,
document_id: documentId,
file_url: fileUrl,
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
} as MediaAsset);
})();
}
};
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock]);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
@@ -628,12 +1027,34 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
return;
}
const bridge = {
undo: () => {
try {
editor.focus();
editor.undo();
} catch {
// ignore
}
},
redo: () => {
try {
editor.focus();
editor.redo();
} catch {
// ignore
}
},
openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId);
},
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
},
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
insertMindmapBlock(args);
},
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
insertOnlineTableBlock(args);
},
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
@@ -683,15 +1104,60 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
},
getCursorBlockId: () => {
try {
const cursor = editor.getTextCursorPosition();
return cursor?.block?.id ?? null;
} catch {
return null;
}
},
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, insertMediaAssetBlock, registerEditorBridge]);
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
useEffect(() => {
if (!editor) return;
if (!workspaceId) return;
const onKeyDown = (event: KeyboardEvent) => {
const ctrlOrMeta = event.ctrlKey || event.metaKey;
if (!ctrlOrMeta) return;
if (!event.altKey) return;
const key = String(event.key ?? "").toLowerCase();
if (key !== "m") return;
event.preventDefault();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
if (blockId) {
openCommentsForBlock({ workspaceId, documentId, blockId });
} else {
openCommentsForPage({ workspaceId, documentId });
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [documentId, editor, openCommentsForBlock, openCommentsForPage, workspaceId]);
useEffect(() => {
if (!editor) {
return undefined;
}
// 说明:BlockNote 的 contenteditable 节点不直接暴露 spellcheck props。
// 这里用 DOM 属性实现“全局选项:拼写检查”。
try {
const root = document.querySelector<HTMLElement>(".wolai-editor");
if (root) {
root.setAttribute("spellcheck", spellCheck ? "true" : "false");
root.querySelectorAll<HTMLElement>("[contenteditable]").forEach((el) => {
el.setAttribute("spellcheck", spellCheck ? "true" : "false");
});
}
} catch {
// ignore
}
const handlePaste = (event: ClipboardEvent) => {
const activeElement = event.target;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
@@ -718,7 +1184,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
useEffect(() => {
if (!editor) {
@@ -777,16 +1243,20 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
editor={editor}
theme="light"
slashMenu={false}
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
sideMenu={false}
editable={!pageOptions.protectEditing && !readOnly}
className={blocknoteClass}
>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
<CustomSideMenu
{...props}
currentDocumentId={documentId}
workspaceId={workspaceId}
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
/>
)}
floatingOptions={{ placement: "left" }}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
@@ -795,7 +1265,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
{isSaving ? "保存中..." : "已保存"}
</div>
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
</div>
<MoveEmbedPickerHost />
@@ -19,6 +19,7 @@ import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useCurrentDocumentStore } from "@/store/current-document";
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
import {
DropdownMenu,
DropdownMenuContent,
@@ -81,6 +82,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
const browserFileUrl = useMemo(() => toBrowserAccessibleUrl(fileUrl) ?? fileUrl, [fileUrl]);
const rawThumbUrl = (block.props.thumbnailUrl as string | undefined) || "";
const browserThumbUrl = useMemo(
() => (rawThumbUrl ? toBrowserAccessibleUrl(rawThumbUrl) ?? rawThumbUrl : ""),
[rawThumbUrl],
);
const rawAssetType = (block.props.assetType as string) || "image";
const assetType: MediaKind =
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
@@ -279,7 +286,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
const openWithOnlyOffice = async () => {
if (!fileUrl) return;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
@@ -400,10 +407,10 @@ const MediaBlockContent = ({ block, editor }: any) => {
<video
controls
className="max-h-[420px] w-full rounded-2xl bg-black"
poster={block.props.thumbnailUrl || undefined}
poster={browserThumbUrl || undefined}
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
>
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
<source src={browserFileUrl} type={block.props.mimeType || "video/mp4"} />
</video>
);
}
@@ -411,7 +418,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
return (
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
<audio controls className="w-full">
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
<source src={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
</audio>
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
</div>
@@ -533,7 +540,13 @@ const MediaBlockContent = ({ block, editor }: any) => {
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
return (
<img
src={browserThumbUrl || browserFileUrl}
alt={block.props.caption || typeLabel}
style={inlineStyle}
/>
);
};
const figure = (
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { useAppPreferencesStore } from "@/store/app-preferences";
type AgentAssetItem = {
kind: "media" | "local-mindmap" | "test-pdf";
@@ -158,11 +159,12 @@ export function MindmapAiAgentPanel({
activeNodes: unknown[];
onClose?: () => void;
}) {
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [networkOn, setNetworkOn] = useState(true);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
const [toolPickerOpen, setToolPickerOpen] = useState(false);
@@ -185,6 +187,12 @@ export function MindmapAiAgentPanel({
const abortRef = useRef<AbortController | null>(null);
const syncTimerRef = useRef<number | null>(null);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
try {
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
@@ -34,6 +34,9 @@ import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { emitAssetsChanged } from "@/lib/events";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { useQuery } from "convex/react";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => {
@@ -462,6 +465,11 @@ const MindmapBlockView = ({
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const lastLocalEditAtRef = useRef(0);
const markLocalEdited = useCallback(() => {
hasLocalEditsRef.current = true;
lastLocalEditAtRef.current = Date.now();
}, []);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
@@ -513,7 +521,10 @@ const MindmapBlockView = ({
if (!docId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
const res = await fetch(
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
);
const json: unknown = await res.json().catch(() => null);
if (!res.ok) return;
if (cancelled) return;
@@ -530,6 +541,7 @@ const MindmapBlockView = ({
useEffect(() => {
hasLocalEditsRef.current = false;
lastLocalEditAtRef.current = 0;
applyingRemoteRef.current = false;
}, [docId]);
@@ -537,6 +549,15 @@ const MindmapBlockView = ({
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
return `${STORAGE_PREFIX}${mindmapId}`;
}, [docId, mindmapId]);
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
const lastLocalSavedAtRef = useRef<string | null>(null);
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
const remoteMindmap = useQuery(
api.mindmaps.get,
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -584,6 +605,83 @@ const MindmapBlockView = ({
};
}, [docId, mindmap, mindmapId]);
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
useEffect(() => {
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
const meta = (remoteMindmap as any).meta as Record<string, unknown> | undefined;
const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null;
const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null;
if (deletedAt) {
if (!docId || deletingRef.current) return;
deletingRef.current = true;
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
// 全屏页:退出到文档页
if (effectiveFullscreen && typeof onExitFullscreen === "function") {
window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。");
onExitFullscreen();
return;
}
// 嵌入编辑器:复用编辑器监听链路移除块
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
return;
}
if (!updatedAt) return;
if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return;
// 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入
if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) {
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
return;
}
// 本地仍有未同步编辑时,不覆盖
if (hasLocalEditsRef.current || deletingRef.current) return;
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
initialDataRef.current = incoming;
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
} catch {
// ignore
}
if (!effectiveFullscreen) {
try {
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
} catch {
// ignore
}
}
if (mindmap) {
applyingRemoteRef.current = true;
try {
mindmap.setData(incoming);
mindmap.command.clearHistory();
} finally {
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 0);
}
}
}, [
autosaveKey,
block,
docId,
editor,
effectiveFullscreen,
mindmap,
mindmapId,
onExitFullscreen,
remoteMindmap,
]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
const onPointerDownCapture = (e: Event) => {
@@ -956,7 +1054,7 @@ const MindmapBlockView = ({
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
// 切换全屏/刷新后不会丢失。
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -974,7 +1072,7 @@ const MindmapBlockView = ({
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -993,7 +1091,7 @@ const MindmapBlockView = ({
const inst = ensureActiveBefore(mm);
if (!inst) return;
inst.execCommand?.("REMOVE_NODE");
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1029,7 +1127,7 @@ const MindmapBlockView = ({
const copyData = renderer.beingCopyData ?? null;
if (copyData) {
inst.execCommand?.("PASTE_NODE", copyData);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1039,7 +1137,7 @@ const MindmapBlockView = ({
return;
}
renderer.paste?.();
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1053,7 +1151,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("keydown", onKeyDownCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
@@ -1114,7 +1212,7 @@ const MindmapBlockView = ({
}
// 兜底持久化:避免快速切换视图导致“看起来没保存”
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1127,7 +1225,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("paste", onPasteCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
@@ -1243,7 +1341,7 @@ const MindmapBlockView = ({
// ignore
}
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1256,7 +1354,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
};
}, []);
}, [markLocalEdited]);
const persistData = useCallback(
(data: unknown) => {
@@ -1279,14 +1377,30 @@ const MindmapBlockView = ({
editor.updateBlock(block, { props: { ...block.props, data: safe } });
}
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
// 同步到本地文件 + Convex(弱依赖)
const requestStartedAt = Date.now();
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
})
.then((resp) => {
.then(async (resp) => {
if (resp.ok) {
try {
const payload = (await resp.json().catch(() => null)) as any;
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
if (updatedAt) {
lastLocalSavedAtRef.current = updatedAt;
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
if (lastLocalEditAtRef.current <= requestStartedAt) {
hasLocalEditsRef.current = false;
}
}
} catch {
// ignore
}
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: mindmapId,
@@ -1699,7 +1813,7 @@ const MindmapBlockView = ({
!deletingRef.current &&
shouldPersistAfterCommand(cmd)
) {
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
@@ -1845,13 +1959,13 @@ const MindmapBlockView = ({
});
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
hasLocalEditsRef.current = true;
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
markLocalEdited();
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
if (snapshot) schedulePersist(snapshot);
@@ -2688,7 +2802,7 @@ const MindmapBlockView = ({
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
if (!confirmed) return;
deletingRef.current = true;
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
@@ -11,7 +11,15 @@ const normalizeTitle = (value?: string | null) => {
return value;
};
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const PageReferenceContent = ({
pageId,
title,
asChildPage,
}: {
pageId: string;
title: string;
asChildPage: boolean;
}) => {
const router = useRouter();
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
// 页面引用的标题会由编辑器同步更新 block.props.title。
@@ -25,6 +33,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
return (
<div
data-child-page={asChildPage ? "true" : "false"}
role="button"
tabIndex={0}
onClick={navigate}
@@ -52,10 +61,17 @@ export const pageReferenceBlock = createReactBlockSpec(
propSchema: {
pageId: { default: "" },
title: { default: "未命名页面" },
asChildPage: { default: false },
},
content: "none",
},
() => ({
render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
render: ({ block }) => (
<PageReferenceContent
pageId={block.props.pageId}
title={block.props.title}
asChildPage={Boolean((block.props as any).asChildPage)}
/>
),
}),
)();
@@ -0,0 +1,508 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { MessageSquare, CornerDownRight, CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { api } from "@/lib/convex/api";
import { useCommentsUiStore } from "@/store/comments-ui";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const makeId = (): string => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
};
const jumpToBlock = (blockId: string) => {
if (!blockId) return;
const target = document.querySelector<HTMLElement>(`[data-id="${blockId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
} else {
window.alert("未找到对应块(可能已被删除或未渲染)");
}
};
export function DocumentCommentsDrawer() {
const { isAuthenticated } = useConvexAuth();
const open = useCommentsUiStore((s) => s.open);
const target = useCommentsUiStore((s) => s.target);
const close = useCommentsUiStore((s) => s.close);
const documentId = target?.documentId ?? "";
const workspaceId = target?.workspaceId ?? "";
const focusBlockId = target?.blockId ?? null;
const [includeResolved, setIncludeResolved] = useState(false);
const threads = useQuery(
api.comments.listThreadsByDocument,
open && isAuthenticated && documentId ? { documentId, includeResolved } : "skip",
);
const currentUser = useQuery(api.users.currentUser, open && isAuthenticated ? {} : "skip");
const createThread = useMutation(api.comments.createThread);
const reply = useMutation(api.comments.reply);
const setResolved = useMutation(api.comments.setResolved);
const editMessage = useMutation(api.comments.editMessage);
const deleteMessage = useMutation(api.comments.deleteMessage);
// 说明:messages 需要 threadId;在选择线程后再订阅。
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const activeMessages = useQuery(
api.comments.listMessagesByThread,
open && isAuthenticated && activeThreadId ? { threadId: activeThreadId } : "skip",
);
const [activeTab, setActiveTab] = useState<"page" | "block">("page");
useEffect(() => {
if (!open) return;
setActiveTab(focusBlockId ? "block" : "page");
setActiveThreadId(null);
setEditingMessageId(null);
setEditingDraft("");
}, [focusBlockId, open]);
const { pageThreads, blockThreads } = useMemo(() => {
const list = Array.isArray(threads) ? threads : [];
const pageThreads = list.filter((t: any) => !t.blockId);
const blockThreads = list.filter((t: any) => Boolean(t.blockId));
return { pageThreads, blockThreads };
}, [threads]);
const blockThreadsForFocus = useMemo(() => {
if (!focusBlockId) return blockThreads;
return blockThreads.filter((t: any) => String(t.blockId) === String(focusBlockId));
}, [blockThreads, focusBlockId]);
const activeThread = useMemo(() => {
const list = Array.isArray(threads) ? threads : [];
return list.find((t: any) => String(t.id) === String(activeThreadId)) ?? null;
}, [activeThreadId, threads]);
const [draft, setDraft] = useState("");
const [replyDraft, setReplyDraft] = useState("");
const [submitting, setSubmitting] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editingDraft, setEditingDraft] = useState("");
const submitNewThread = async (blockId: string | null) => {
if (!documentId || !workspaceId) return;
const body = draft.trim();
if (!body) {
window.alert("请输入评论内容");
return;
}
setSubmitting(true);
try {
const threadId = makeId();
const messageId = makeId();
await createThread({
id: threadId,
documentId,
workspaceId,
blockId,
messageId,
body,
});
setDraft("");
setActiveThreadId(threadId);
} catch (e) {
window.alert(e instanceof Error ? e.message : "创建评论失败");
} finally {
setSubmitting(false);
}
};
const submitReply = async () => {
if (!activeThreadId) return;
const body = replyDraft.trim();
if (!body) {
window.alert("请输入回复内容");
return;
}
setSubmitting(true);
try {
await reply({ threadId: activeThreadId, messageId: makeId(), body });
setReplyDraft("");
} catch (e) {
window.alert(e instanceof Error ? e.message : "回复失败");
} finally {
setSubmitting(false);
}
};
const submitEdit = async () => {
if (!editingMessageId) return;
const body = editingDraft.trim();
if (!body) {
window.alert("请输入评论内容");
return;
}
setSubmitting(true);
try {
await editMessage({ messageId: editingMessageId, body });
setEditingMessageId(null);
setEditingDraft("");
} catch (e) {
window.alert(e instanceof Error ? e.message : "编辑失败");
} finally {
setSubmitting(false);
}
};
const submitDelete = async (messageId: string) => {
const ok = window.confirm("确定删除这条评论吗?");
if (!ok) return;
setSubmitting(true);
try {
await deleteMessage({ messageId });
if (editingMessageId === messageId) {
setEditingMessageId(null);
setEditingDraft("");
}
} catch (e) {
window.alert(e instanceof Error ? e.message : "删除失败");
} finally {
setSubmitting(false);
}
};
const toggleResolved = async () => {
if (!activeThreadId || !activeThread) return;
const next = !activeThread.resolvedAt;
setSubmitting(true);
try {
await setResolved({ threadId: activeThreadId, resolved: next });
} catch (e) {
window.alert(e instanceof Error ? e.message : "更新状态失败");
} finally {
setSubmitting(false);
}
};
const ThreadList = ({ list }: { list: any[] }) => {
if (!list.length) {
return (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
);
}
return (
<div className="space-y-2">
{list.map((t) => {
const isActive = String(t.id) === String(activeThreadId);
const resolved = Boolean(t.resolvedAt);
return (
<button
key={t.id}
type="button"
className={cn(
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors",
isActive ? "border-[#c7d2fe] bg-[#eef2ff]" : "border-[#e2e8f0] bg-white hover:bg-gray-50",
)}
onClick={() => setActiveThreadId(t.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs text-gray-500">
{resolved ? (
<span className="inline-flex items-center gap-1 text-green-700">
<CheckCircle2 className="h-4 w-4" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-gray-500">
<Circle className="h-4 w-4" />
</span>
)}
<span>·</span>
<span>{t.commentCount} </span>
{t.blockId ? (
<>
<span>·</span>
<span className="inline-flex items-center gap-1">
<ExternalLink
className="h-3.5 w-3.5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
jumpToBlock(String(t.blockId));
}}
/>
</span>
</>
) : null}
</div>
<div className="mt-1 truncate font-medium text-gray-900">
{t.lastCommentPreview || "(无预览)"}
</div>
<div className="mt-1 text-xs text-gray-500">
{t.lastCommentBy?.name || "匿名"} · {new Date(t.lastActivityAt).toLocaleString()}
</div>
</div>
</div>
</button>
);
})}
</div>
);
};
const ThreadDetail = () => {
if (!activeThreadId || !activeThread) {
return (
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
</div>
);
}
const list = Array.isArray(activeMessages) ? activeMessages : [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3">
<div className="text-sm font-medium text-gray-800">
{activeThread.blockId ? "块评论" : "页面评论"}
</div>
<div className="flex items-center gap-2">
{activeThread.blockId ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jumpToBlock(String(activeThread.blockId))}
>
</Button>
) : null}
<Button type="button" size="sm" variant="outline" onClick={toggleResolved} disabled={submitting}>
{activeThread.resolvedAt ? "取消解决" : "标记解决"}
</Button>
</div>
</div>
<div className="max-h-[38vh] space-y-2 overflow-y-auto rounded-lg border border-[#eef2ff] bg-[#fbfbff] p-3">
{list.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : (
list.map((m: any) => (
<div key={m.id} className="rounded-md border border-[#e2e8f0] bg-white p-3 text-sm">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{m.createdBy?.name || "匿名"}</span>
<span>{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className={cn("mt-2 whitespace-pre-wrap text-gray-800", m.deletedAt && "text-gray-400")}>
{m.deletedAt ? (
"该评论已删除"
) : editingMessageId === String(m.id) ? (
<div className="space-y-2">
<Textarea
value={editingDraft}
onChange={(e) => setEditingDraft(e.target.value)}
className="min-h-[90px]"
disabled={submitting}
/>
<div className="flex gap-2">
<Button type="button" size="sm" onClick={submitEdit} disabled={submitting}>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingMessageId(null);
setEditingDraft("");
}}
disabled={submitting}
>
</Button>
</div>
</div>
) : (
m.body
)}
</div>
{!m.deletedAt && editingMessageId !== String(m.id) ? (
<div className="mt-2 flex gap-2 text-xs">
{currentUser && String((currentUser as any)?._id ?? "") === String(m.createdBy?.id ?? "") ? (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditingMessageId(String(m.id));
setEditingDraft(String(m.body ?? ""));
}}
disabled={submitting}
>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => submitDelete(String(m.id))}
disabled={submitting}
>
</Button>
</>
) : null}
</div>
) : null}
</div>
))
)}
</div>
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<CornerDownRight className="h-4 w-4" />
</div>
<Textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
placeholder="输入回复内容..."
className="mt-2 min-h-20"
/>
<div className="mt-2 flex justify-end">
<Button type="button" onClick={submitReply} disabled={submitting}>
</Button>
</div>
</div>
</div>
);
};
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<DrawerContent className="max-h-[92vh]">
<DrawerHeader className="text-left">
<DrawerTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
</DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
{!isAuthenticated ? (
<div className="px-4 pb-6 text-sm text-gray-500"></div>
) : !documentId ? (
<div className="px-4 pb-6 text-sm text-gray-500"> documentId</div>
) : (
<div className="grid gap-4 px-4 pb-6 lg:grid-cols-2">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-gray-800">线</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setIncludeResolved((prev) => !prev)}
>
{includeResolved ? "隐藏已解决" : "显示已解决"}
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
<TabsList className="w-full">
<TabsTrigger value="page" className="flex-1">
{pageThreads.length}
</TabsTrigger>
<TabsTrigger value="block" className="flex-1">
{blockThreads.length}
</TabsTrigger>
</TabsList>
<TabsContent value="page" className="mt-3 space-y-3">
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="text-xs font-medium text-gray-600"></div>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="输入评论内容..."
className="mt-2 min-h-20"
/>
<div className="mt-2 flex justify-end">
<Button type="button" onClick={() => submitNewThread(null)} disabled={submitting}>
</Button>
</div>
</div>
<ThreadList list={pageThreads} />
</TabsContent>
<TabsContent value="block" className="mt-3 space-y-3">
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
<div className="text-xs font-medium text-gray-600"></div>
<div className="mt-2 flex items-center gap-2">
<Input
value={focusBlockId ?? ""}
readOnly
placeholder="从块菜单进入后会自动带上 blockId"
className="h-9 text-xs"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={!focusBlockId}
onClick={() => (focusBlockId ? jumpToBlock(focusBlockId) : null)}
>
</Button>
</div>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={focusBlockId ? "对该块发表评论..." : "请从块菜单进入以指定 blockId"}
className="mt-2 min-h-20"
disabled={!focusBlockId}
/>
<div className="mt-2 flex justify-end">
<Button
type="button"
onClick={() => submitNewThread(focusBlockId)}
disabled={submitting || !focusBlockId}
>
</Button>
</div>
</div>
<ThreadList list={blockThreadsForFocus} />
</TabsContent>
</Tabs>
</div>
<div className="space-y-3">
<div className="text-sm font-semibold text-gray-800">线</div>
<ThreadDetail />
</div>
</div>
)}
</DrawerContent>
</Drawer>
);
}
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
import { useEditorBridgeStore } from "@/store/editor-bridge";
@@ -10,12 +10,17 @@ import { usePageLayoutStore } from "@/store/page-layout";
import { useCurrentDocumentStore } from "@/store/current-document";
import type { Json } from "@/types/supabase";
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
import { useRouter } from "next/navigation";
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
import { useCommentsUiStore } from "@/store/comments-ui";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { cn } from "@/lib/utils";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -49,8 +54,14 @@ const defaultOptions: PageOptionsState = {
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
export function DocumentContent({
documentId,
@@ -74,6 +85,9 @@ export function DocumentContent({
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const router = useRouter();
const showInspector = usePageLayoutStore((state) => state.showInspector);
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
@@ -314,15 +328,221 @@ export function DocumentContent({
[documentId, readOnly],
);
const toggleOption = (key: keyof PageOptionsState) => {
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
},
[persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
});
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
return next;
});
};
}, [persistOptions, readOnly]);
const handleSetPageFont = useCallback(
(font: PageFont) => {
setOptionPatch({ pageFont: font });
},
[setOptionPatch],
);
const handleSetLayoutDensity = useCallback(
(density: PageLayoutDensity) => {
setOptionPatch({ layoutDensity: density });
},
[setOptionPatch],
);
const handleSetEmbedDefaultToCursor = useCallback(() => {
if (readOnly) return;
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
if (!blockId) {
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
return;
}
setOptionPatch({ embedDefaultBlockId: blockId });
window.alert("已设置“嵌入默认位置”");
}, [editorBridge, readOnly, setOptionPatch]);
const handleClearEmbedDefault = useCallback(() => {
if (readOnly) return;
setOptionPatch({ embedDefaultBlockId: null });
window.alert("已清除“嵌入默认位置”");
}, [readOnly, setOptionPatch]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const root = pageRootRef.current;
if (root) {
const target = event.target;
if (target && target instanceof Node && !root.contains(target)) {
return;
}
}
const ctrlOrMeta = event.ctrlKey || event.metaKey;
if (!ctrlOrMeta) return;
if (!event.shiftKey) return;
const key = String(event.key ?? "").toLowerCase();
if (key === "l") {
event.preventDefault();
toggleOption("showToc");
} else if (key === "c") {
event.preventDefault();
toggleOption("protectEditing");
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [toggleOption]);
const buildDocumentUrl = useCallback((id: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${id}`;
}
return `${window.location.origin}/documents/${id}`;
}, []);
const copyText = useCallback(async (text: string, successMessage: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
window.alert(successMessage);
return;
} catch {
// ignore and fallback
}
}
window.prompt("复制失败,请手动复制内容", text);
}, []);
const handleCopyPageLink = useCallback(
async (includeTitle: boolean) => {
const url = buildDocumentUrl(documentId);
if (includeTitle) {
const text = `${pageTitle || "无标题"}\n${url}`;
await copyText(text, "标题 + 链接已复制");
return;
}
await copyText(url, "页面链接已复制");
},
[buildDocumentUrl, copyText, documentId, pageTitle],
);
const handleCopyPageReference = useCallback(
async (mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${documentId}))` : `{{${documentId}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
},
[copyText, documentId],
);
const handleUndo = useCallback(() => {
editorBridge?.undo?.();
}, [editorBridge]);
const handleRedo = useCallback(() => {
editorBridge?.redo?.();
}, [editorBridge]);
const handleDeletePage = useCallback(async () => {
if (readOnly) return;
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
if (!ok) return;
const resp = await fetch("/api/documents/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除失败");
return;
}
router.push("/");
router.refresh();
}, [documentId, readOnly, router]);
const handleOpenMoveEmbed = useCallback(() => {
if (readOnly) return;
openMoveEmbedPicker({
workspaceId,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: true,
excludeIds: [documentId],
onPick: async (mode, targetId) => {
if (mode === "move") {
const resp = await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
return;
}
window.alert("移动成功");
router.refresh();
return;
}
const resp = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: documentId, targetId }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "嵌入失败");
return;
}
window.alert("已嵌入到目标页面");
},
});
}, [documentId, openMoveEmbedPicker, readOnly, router, workspaceId]);
const handleAddToTemplates = useCallback(async () => {
if (readOnly) return;
const ok = window.confirm("将该页面添加为模板?");
if (!ok) return;
const resp = await fetch("/api/documents/template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, isTemplate: true }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "设置模板失败");
return;
}
window.alert("已添加为模板");
router.refresh();
}, [documentId, readOnly, router]);
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
@@ -349,6 +569,16 @@ export function DocumentContent({
URL.revokeObjectURL(url);
}, [disableDownload, history, title]);
const pageRootClass = cn(
"flex h-full overflow-hidden bg-wolai-bg",
options.pageFont === "song" && "wolai-page-font-song",
options.pageFont === "kai" && "wolai-page-font-kai",
options.layoutDensity === "compact" && "wolai-page-density-compact",
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
@@ -398,7 +628,7 @@ export function DocumentContent({
return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
<div className={pageRootClass} ref={pageRootRef}>
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="relative">
@@ -411,6 +641,7 @@ export function DocumentContent({
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing || readOnly}
spellCheck={spellCheck}
/>
</div>
{readOnly ? (
@@ -453,9 +684,15 @@ export function DocumentContent({
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
/>
)}
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
<PageBacklinksPanel
className="mt-10"
workspaceId={workspaceId}
documentId={documentId}
defaultCollapsed={options.collapseBacklinks}
/>
</div>
</div>
{showInspector && (
@@ -464,8 +701,20 @@ export function DocumentContent({
options={options}
stats={stats}
onToggle={toggleOption}
onSetPageFont={handleSetPageFont}
onSetLayoutDensity={handleSetLayoutDensity}
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
onClearEmbedDefault={handleClearEmbedDefault}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
onUndo={handleUndo}
onRedo={handleRedo}
onDeletePage={handleDeletePage}
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
onCopyPageLink={handleCopyPageLink}
onCopyPageReference={handleCopyPageReference}
onAddToTemplates={handleAddToTemplates}
/>
)}
</div>
@@ -475,6 +724,7 @@ export function DocumentContent({
history={history}
onRestore={handleRestoreSnapshot}
/>
<DocumentCommentsDrawer />
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
</ImagePickerProvider>
);
@@ -1,6 +1,18 @@
"use client";
import { useMemo, useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface TocEntry {
id: string;
@@ -13,19 +25,54 @@ interface DocumentTocProps {
entries: TocEntry[];
visible: boolean;
onJump: (id: string) => void;
onClose?: () => void;
}
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
export function DocumentToc({ entries, visible, onJump, onClose }: DocumentTocProps) {
const [maxLevel, setMaxLevel] = useState<number>(4);
const [showFullTitle, setShowFullTitle] = useState(false);
const filteredEntries = useMemo(() => entries.filter((entry) => entry.level <= maxLevel), [entries, maxLevel]);
if (!visible || entries.length === 0) {
return null;
}
return (
<div className="pointer-events-none absolute right-0 top-0 z-10 hidden lg:block">
<div className="pointer-events-auto mt-2 w-48 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 text-[11px] font-semibold text-gray-400"></div>
<div className="pointer-events-auto mt-2 w-[min(320px,22vw)] min-w-40 max-w-80 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
<div className="mb-2 flex items-center justify-between">
<div className="text-[11px] font-semibold text-gray-400"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
aria-label="标题目录菜单"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={String(maxLevel)} onValueChange={(v) => setMaxLevel(Number(v) || 4)}>
<DropdownMenuRadioItem value="1"> H1</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="2"> H2</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="3"> H3</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="4"> H4</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showFullTitle} onCheckedChange={(v) => setShowFullTitle(Boolean(v))}>
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onClose?.()} disabled={!onClose}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ul className="space-y-1">
{entries.map((entry) => (
{filteredEntries.map((entry) => (
<li key={entry.id}>
<button
type="button"
@@ -33,6 +80,7 @@ export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
"w-full rounded-md px-2 py-1 text-left text-[11px] text-gray-500 transition-colors hover:bg-[#eef2ff] hover:text-[#2563eb]",
entry.level > 1 && "pl-4",
entry.level > 2 && "pl-6",
showFullTitle ? "whitespace-normal" : "truncate",
)}
onClick={() => onJump(entry.id)}
>
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
@@ -18,6 +18,7 @@ import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { useCommentsUiStore } from "@/store/comments-ui";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -55,11 +56,57 @@ const extractText = (block: Block<CustomBlockSchema>) => {
return "未命名页面";
};
const clearMindmapAutosaveCache = (targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const prefix = "wolai-mindmap-autosave-";
const targetPrefix = `${prefix}${targetDocumentId}`;
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i += 1) {
const k = window.localStorage.key(i);
if (!k) continue;
if (mindmapId) {
if (k === directKey) keys.push(k);
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
keys.push(k);
}
}
keys.forEach((k) => window.localStorage.removeItem(k));
} catch {
// ignore
}
};
const markMindmapDeleting = (targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const w = window as unknown as {
__wolaiMindmapDeletingKeys?: Set<string>;
};
if (!w.__wolaiMindmapDeletingKeys) {
w.__wolaiMindmapDeletingKeys = new Set<string>();
}
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
w.__wolaiMindmapDeletingKeys.add(key);
window.setTimeout(() => {
try {
w.__wolaiMindmapDeletingKeys?.delete(key);
} catch {
// ignore
}
}, 8000);
} catch {
// ignore
}
};
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
@@ -93,8 +140,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
if (block.type === "onlineTable") {
const tableId = block.props.tableId as string | undefined;
if (tableId) {
void deleteOnlineTable(tableId)
.catch((error) => console.error("删除在线表格失败", error));
try {
await deleteOnlineTable(tableId);
} catch (error) {
console.error("删除在线表格失败", error);
window.alert("删除在线表格失败,请稍后重试");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
emitAssetsChanged(currentDocumentId);
@@ -122,6 +174,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
return;
}
if (block.type === "mindmap") {
// 关键:必须先标记“删除中”,避免 MindmapBlock 卸载清理把数据 POST 回去导致“删除后复活”。
markMindmapDeleting(currentDocumentId, block.id);
clearMindmapAutosaveCache(currentDocumentId, block.id);
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
@@ -162,7 +217,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
[
{
type: "pageReference",
props: { pageId, title },
props: { pageId, title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
@@ -365,7 +420,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("评论功能暂未开放")}
onClick={() => {
if (!workspaceId) {
window.alert("缺少 workspaceId,无法打开评论");
return;
}
openCommentsForBlock({ workspaceId, documentId: currentDocumentId, blockId: block.id });
}}
>
</Components.Generic.Menu.Item>
@@ -418,14 +479,27 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
unresolvedCommentCountByBlockId?: Record<string, number>;
};
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const Components = useComponentsContext()!;
const { editor, block, blockDragStart, blockDragEnd, freezeMenu, unfreezeMenu, currentDocumentId, workspaceId } = props;
const [activeLine, setActiveLine] = useState<null | "top" | "bottom">(null);
const {
editor,
block,
blockDragStart,
blockDragEnd,
freezeMenu,
unfreezeMenu,
currentDocumentId,
workspaceId,
unresolvedCommentCountByBlockId,
} = props;
const [insertHovered, setInsertHovered] = useState<null | "top" | "bottom">(null);
const [menuOpen, setMenuOpen] = useState(false);
const [hovering, setHovering] = useState(false);
const [activeCursorBlockId, setActiveCursorBlockId] = useState<string | null>(null);
const hoverAreaRef = useRef<HTMLDivElement | null>(null);
const menuFrozenRef = useRef(false);
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
@@ -433,8 +507,19 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
const hoverPadPx = 14;
const lineOffsetPx = 6;
const lineGapPx = 5;
const lineGapPx = 3;
const insertBtnSizePx = 16;
const handleBtnSizePx = 22;
const unresolvedCount = unresolvedCommentCountByBlockId?.[block.id] ?? 0;
useEffect(() => {
const update = () => {
const cursor = editor.getTextCursorPosition();
setActiveCursorBlockId(cursor?.block?.id ?? null);
};
update();
return editor.onSelectionChange(update);
}, [editor]);
const setFrozen = useCallback(
(next: boolean) => {
@@ -449,6 +534,32 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
[freezeMenu, unfreezeMenu],
);
useEffect(() => {
// 说明:Win+Shift+S 截图会触发窗口失焦/可见性变化;若用右键取消,
// 有些环境下不会触发正常的 mouseleave,导致手柄状态卡死(看起来像“消失”)。
// 这里在失焦/隐藏时强制解除冻结并重置 hover 状态。
const reset = () => {
setHovering(false);
setMenuOpen(false);
setInsertHovered(null);
setFrozen(false);
};
const onBlur = () => reset();
const onVisibility = () => {
if (document.visibilityState === "hidden") {
reset();
}
};
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [setFrozen]);
const insertParagraph = useCallback(
(position: "before" | "after") => {
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
@@ -465,6 +576,47 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
e.stopPropagation();
};
const paragraphPlainText = useMemo(() => {
if (block.type !== "paragraph") return null;
const content = Array.isArray(block.content) ? (block.content as any[]) : [];
const text = content
.map((node) =>
node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""
)
.join("");
return text;
}, [block]);
const isEmptyParagraph = block.type === "paragraph" && (paragraphPlainText ?? "").trim().length === 0;
const showEmptyPlus = isEmptyParagraph && (activeCursorBlockId === block.id || hovering || menuOpen);
const openSlashMenuFromEmptyPlus = (e: ReactMouseEvent) => {
stop(e);
if (!isEmptyParagraph) return;
try {
editor.setTextCursorPosition(block as any, "start");
} catch {
// ignore
}
editor.focus();
// 说明:按 Wolai 手感,点击“+”等同于在空行输入 “/” 打开斜杠菜单。
// deleteTriggerCharacter=true 会把 “/” 写入编辑器,并在选择条目后由插件清理掉。
editor.openSuggestionMenu("/", { deleteTriggerCharacter: true, ignoreQueryLength: true });
};
const forceShowInsertButtons = block.type === "mindmap" || block.type === "onlineTable";
// 说明:思维导图/在线表格等嵌入块内部可能接管鼠标事件,导致 hover 状态不稳定。
// 对这些块直接常驻显示插入控件,避免“看不到横杠/加号”。
const showInsertButtons = !showEmptyPlus && (forceShowInsertButtons || hovering || menuOpen);
const handleCenterY = hoverPadPx + rowHeightPx / 2;
// 说明:插入按钮的位置必须“跟着手柄走”,不能依赖容器上下边界。
// 否则对于思维导图/在线表格等高块,容器可能被撑高,导致按钮跑到块底部。
// 说明:插入按钮不能与六点手柄发生重叠,否则 hover/click 会被手柄拦截(表现为“看得见但点不到/hover 没反应”)。
// 这里用“手柄按钮尺寸 + 插入按钮尺寸 + 间距”计算中心距,确保永不重叠。
const insertDistPx = handleBtnSizePx / 2 + insertBtnSizePx / 2 + lineGapPx;
const insertBeforeTopPx = handleCenterY - insertDistPx - insertBtnSizePx / 2;
const insertAfterTopPx = handleCenterY + insertDistPx - insertBtnSizePx / 2;
return (
<Components.Generic.Menu.Root
onOpenChange={(open: boolean) => {
@@ -474,70 +626,122 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
position={"left"}
>
<div
ref={hoverAreaRef}
data-testid="wolai-handle-area"
className="relative w-7 overflow-visible"
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
onMouseEnter={() => {
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onMouseLeave={() => {
onPointerLeave={() => {
setHovering(false);
setActiveLine(null);
setInsertHovered(null);
setFrozen(menuOpen || false);
}}
>
{activeLine !== "bottom" && (
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("top")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{activeLine === "top" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
)}
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertBeforeTopPx }}
onPointerEnter={() => setInsertHovered("top")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{insertHovered === "top"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
<div
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
className="absolute left-1/2 z-[2147483647] -translate-x-1/2 -translate-y-1/2"
style={{ top: hoverPadPx + rowHeightPx / 2 }}
onMouseEnter={() => setActiveLine(null)}
>
<Components.Generic.Menu.Trigger>
{showEmptyPlus ? (
<button
type="button"
data-testid="wolai-empty-plus"
aria-label="打开斜杠命令"
className="flex h-[22px] w-[22px] items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
onMouseDown={stop}
onClick={openSlashMenuFromEmptyPlus}
>
<Plus className="h-4 w-4" />
</button>
) : null}
{!showEmptyPlus ? (
<Components.Generic.Menu.Trigger>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
className={"bn-button bn-drag-handle"}
icon={<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />}
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave
// 这里用 :hover 兜底,避免插入按钮一闪而过。
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering && !menuOpen) {
setHovering(false);
setInsertHovered(null);
setFrozen(false);
}
});
}}
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
icon={
<span className="relative inline-flex">
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
{unresolvedCount > 0 ? (
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
{unresolvedCount > 9 ? "9+" : unresolvedCount}
</span>
) : null}
</span>
}
/>
</Components.Generic.Menu.Trigger>
</Components.Generic.Menu.Trigger>
) : null}
</div>
{activeLine !== "top" && (
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ bottom: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("bottom")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{activeLine === "bottom" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
)}
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertAfterTopPx }}
onPointerEnter={() => setInsertHovered("bottom")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{insertHovered === "bottom"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
</div>
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
@@ -262,7 +262,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const { pageId, title } = await response.json();
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "pageReference",
props: { pageId, title },
props: { pageId, title, asChildPage: true },
content: [],
});
router.refresh();
@@ -373,6 +373,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
multiple: true,
onSelect: (selection) => {
insertMediaSelection({
...selection,
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { useBacklinks } from "@/hooks/use-backlinks";
import type { BacklinkRecord } from "@/types/references";
@@ -10,6 +10,7 @@ interface PageBacklinksPanelProps {
workspaceId: string;
documentId: string;
className?: string;
defaultCollapsed?: boolean;
}
const formatRelative = (value: string) => {
@@ -37,13 +38,16 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
</div>
);
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId,
documentId,
});
const records = useMemo(() => data ?? [], [data]);
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
if (!error && records.length === 0 && (isLoading || isFetching)) {
@@ -65,12 +69,29 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
{isFetching ? "刷新中..." : "刷新"}
</Button>
</div>
{records.length > 0 && (
<div className="mb-4 flex items-center justify-between text-xs text-gray-500">
<span> {records.length} </span>
<Button
size="sm"
variant="ghost"
onClick={() => setIsCollapsed((prev) => !prev)}
className="h-7 px-2"
>
{isCollapsed ? "展开" : "折叠"}
</Button>
</div>
)}
{isLoading ? (
<div className="py-6 text-center text-sm text-gray-500">...</div>
) : error ? (
<div className="py-6 text-center text-sm text-red-500">{(error as Error).message}</div>
) : records.length === 0 ? (
<EmptyState />
) : isCollapsed ? (
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-4 text-center text-xs text-gray-500">
</div>
) : (
<div className="space-y-3">
{records.map((record) => (
@@ -1,11 +1,12 @@
"use client";
import { useState, type ComponentType } from "react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
type TabId = "page" | "custom" | "global";
@@ -16,7 +17,7 @@ const TABS: Array<{ id: TabId; label: string }> = [
];
const OPTION_META: Record<
keyof PageOptionsState,
BooleanPageOptionKey,
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
> = {
wideLayout: {
@@ -39,11 +40,6 @@ const OPTION_META: Record<
description: "在右侧展示目录导航",
icon: ListTree,
},
showStructure: {
label: "块结构线框",
description: "显示块级元素的结构边界",
icon: Focus,
},
protectEditing: {
label: "编辑保护",
description: "保护内容避免误触修改",
@@ -54,19 +50,53 @@ const OPTION_META: Record<
description: "实时展示字数和块统计",
icon: BookOpenCheck,
},
collapseBacklinks: {
label: "折叠反向引用",
description: "默认折叠页面底部的反向引用列表",
icon: Focus,
},
hideChildPages: {
label: "隐藏子页面",
description: "隐藏通过 /ym 创建的子页面块(不会删除内容)",
icon: Focus,
},
showBlockRefCount: {
label: "显示块引用数字",
description: "显示块被引用次数(当前为占位,后续补齐)",
icon: Focus,
},
};
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
];
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
stats?: DocumentStats;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
onSetPageFont?: (font: PageFont) => void;
onSetLayoutDensity?: (density: PageLayoutDensity) => void;
onSetEmbedDefaultToCursor?: () => void;
onClearEmbedDefault?: () => void;
onExport: () => void;
onOpenHistory: () => void;
onOpenComments?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onDeletePage?: () => void;
onOpenMoveEmbedPicker?: () => void;
onCopyPageLink?: (includeTitle: boolean) => void;
onCopyPageReference?: (mode: "inline" | "embed") => void;
onAddToTemplates?: () => void;
}
export function PageOptionsSidebar({
@@ -74,10 +104,30 @@ export function PageOptionsSidebar({
options,
stats,
onToggle,
onSetPageFont,
onSetLayoutDensity,
onSetEmbedDefaultToCursor,
onClearEmbedDefault,
onExport,
onOpenHistory,
onOpenComments,
onUndo,
onRedo,
onDeletePage,
onOpenMoveEmbedPicker,
onCopyPageLink,
onCopyPageReference,
onAddToTemplates,
}: PageOptionsSidebarProps) {
const [activeTab, setActiveTab] = useState<TabId>("page");
const theme = useAppPreferencesStore((s) => s.theme);
const showStructure = useAppPreferencesStore((s) => s.showStructure);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const setTheme = useAppPreferencesStore((s) => s.setTheme);
const setShowStructure = useAppPreferencesStore((s) => s.setShowStructure);
const setSpellCheck = useAppPreferencesStore((s) => s.setSpellCheck);
const setFlightMode = useAppPreferencesStore((s) => s.setFlightMode);
return (
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
@@ -107,8 +157,13 @@ export function PageOptionsSidebar({
<StatsCell label="字符" value={stats.characterCount} />
<StatsCell label="块数" value={stats.blockCount} />
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-center text-xs text-gray-500">
<StatsCell label="待办总数" value={stats.todoTotal} />
<StatsCell label="已完成" value={stats.todoDone} />
</div>
</section>
)}
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
@@ -119,34 +174,197 @@ export function PageOptionsSidebar({
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onOpenComments}
disabled={!onOpenComments}
title={onOpenComments ? "打开评论" : "评论功能未启用"}
>
<MessageSquare className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
<p className="mt-2 text-xs text-gray-400"></p>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="text-sm font-semibold text-gray-800"></div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={onUndo} disabled={!onUndo}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={onRedo} disabled={!onRedo}>
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onOpenMoveEmbedPicker}
disabled={!onOpenMoveEmbedPicker}
>
/...
</Button>
<Button type="button" size="sm" variant="outline" onClick={onAddToTemplates} disabled={!onAddToTemplates}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(false)} disabled={!onCopyPageLink}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(true)} disabled={!onCopyPageLink}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("inline")} disabled={!onCopyPageReference}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("embed")} disabled={!onCopyPageReference}>
</Button>
<Button type="button" size="sm" variant="destructive" onClick={onDeletePage} disabled={!onDeletePage}>
</Button>
</div>
<p className="mt-2 text-xs text-gray-400">
<span className="font-mono">Ctrl/Cmd + Shift + L</span>
</p>
</section>
<DocumentTaskPanel documentId={documentId} />
</div>
)}
{activeTab === "custom" && (
<div className="space-y-5">
<OptionToggleGroup
title="布局与排版"
optionKeys={CUSTOM_LAYOUT_OPTIONS}
options={options}
onToggle={onToggle}
/>
<OptionToggleGroup
title="结构与目录"
optionKeys={CUSTOM_STRUCTURE_OPTIONS}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400"></p>
<div className="mt-3 flex flex-wrap gap-2">
{([
{ id: "default", label: "默认" },
{ id: "song", label: "宋体" },
{ id: "kai", label: "楷体" },
] as Array<{ id: PageFont; label: string }>).map((item) => (
<Button
key={item.id}
type="button"
size="sm"
variant={options.pageFont === item.id ? "default" : "outline"}
onClick={() => onSetPageFont?.(item.id)}
disabled={!onSetPageFont}
>
{item.label}
</Button>
))}
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400"></p>
<div className="mt-3 flex flex-wrap gap-2">
{([
{ id: "compact", label: "紧凑" },
{ id: "normal", label: "默认" },
{ id: "spacious", label: "宽容" },
] as Array<{ id: PageLayoutDensity; label: string }>).map((item) => (
<Button
key={item.id}
type="button"
size="sm"
variant={options.layoutDensity === item.id ? "default" : "outline"}
onClick={() => onSetLayoutDensity?.(item.id)}
disabled={!onSetLayoutDensity}
>
{item.label}
</Button>
))}
</div>
</section>
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400">
/...
</p>
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
</div>
<div className="mt-3 flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={onSetEmbedDefaultToCursor}
disabled={!onSetEmbedDefaultToCursor}
>
使
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onClearEmbedDefault}
disabled={!onClearEmbedDefault || !options.embedDefaultBlockId}
>
</Button>
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-xs text-gray-500">
<div className="text-sm font-semibold text-gray-800"></div>
<ul className="mt-2 list-disc space-y-1 pl-4">
<li></li>
</ul>
</section>
</div>
)}
{activeTab === "global" && (
<div className="space-y-5">
<OptionToggleGroup title="全局偏好" optionKeys={GLOBAL_OPTIONS} options={options} onToggle={onToggle} />
<section className="rounded-2xl border border-dashed border-[#e3e3e3] p-4 text-xs text-gray-400">
Good Night
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<PreferenceRow
title="显示块结构"
description="显示块级元素的结构边界(虚线框)。快捷键:Ctrl/Cmd + Shift + U。"
enabled={showStructure}
onToggle={() => setShowStructure(!showStructure)}
/>
<PreferenceRow
title="拼写检查"
description="控制编辑器的浏览器拼写检查(spellcheck)。"
enabled={spellCheck}
onToggle={() => setSpellCheck(!spellCheck)}
/>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<p className="mt-1 text-xs text-gray-400">Good NightCtrl/Cmd + Alt/Opt + G</p>
<div className="mt-3 flex gap-2">
{(["system", "light", "dark"] as ThemeMode[]).map((mode) => (
<Button
key={mode}
type="button"
size="sm"
variant={theme === mode ? "default" : "outline"}
onClick={() => setTheme(mode)}
>
{mode === "system" ? "跟随系统" : mode === "dark" ? "深色" : "浅色"}
</Button>
))}
</div>
</section>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
<PreferenceRow
title="飞行模式"
description="开启后默认关闭 AI 面板的“联网”开关(避免误触外部网络)。"
enabled={flightMode}
onToggle={() => setFlightMode(!flightMode)}
/>
</section>
</div>
)}
@@ -162,9 +380,9 @@ function OptionToggleGroup({
onToggle,
}: {
title: string;
optionKeys: (keyof PageOptionsState)[];
optionKeys: BooleanPageOptionKey[];
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
}) {
return (
<section>
@@ -183,9 +401,9 @@ function OptionToggle({
options,
onToggle,
}: {
optionKey: keyof PageOptionsState;
optionKey: BooleanPageOptionKey;
options: PageOptionsState;
onToggle: (key: keyof PageOptionsState) => void;
onToggle: (key: BooleanPageOptionKey) => void;
}) {
const meta = OPTION_META[optionKey];
const Icon = meta.icon;
@@ -226,3 +444,27 @@ function StatsCell({ label, value }: { label: string; value: number }) {
</div>
);
}
function PreferenceRow({
title,
description,
enabled,
onToggle,
}: {
title: string;
description: string;
enabled: boolean;
onToggle: () => void;
}) {
return (
<div className="mt-3 flex items-start justify-between gap-3 rounded-xl bg-[#f9fafc] px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900">{title}</div>
<div className="text-xs text-gray-400">{description}</div>
</div>
<Button type="button" size="sm" variant={enabled ? "default" : "outline"} onClick={onToggle}>
{enabled ? "已开启" : "已关闭"}
</Button>
</div>
);
}
@@ -0,0 +1,90 @@
import React, { useEffect, act } from "react";
import { describe, expect, test, vi } from "vitest";
import { createRoot } from "react-dom/client";
import type { MediaSelection } from "@/types/media";
import { ImagePickerProvider, useImagePicker } from "./image-picker-context";
// React 18+:需要显式开启 act 环境标记,避免警告。
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let lastDialogProps: null | { open: boolean; onSelect: (selection: MediaSelection) => void } = null;
vi.mock("@/components/media/image-picker-dialog", () => ({
ImagePickerDialog: (props: { open: boolean; onSelect: (selection: MediaSelection) => void }) => {
lastDialogProps = props;
return null;
},
}));
function Harness(props: { multiple: boolean; onSelect: (selection: MediaSelection) => void }) {
const { openPicker } = useImagePicker();
useEffect(() => {
openPicker({
mediaType: "file",
multiple: props.multiple,
onSelect: props.onSelect,
});
}, [openPicker, props.multiple, props.onSelect]);
return null;
}
describe("ImagePickerProvider", () => {
test("single 模式:选择后自动关闭", () => {
lastDialogProps = null;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onSelect = vi.fn();
act(() => {
root.render(
<ImagePickerProvider documentId="doc" workspaceId="ws">
<Harness multiple={false} onSelect={onSelect} />
</ImagePickerProvider>,
);
});
expect(lastDialogProps?.open).toBe(true);
act(() => {
lastDialogProps?.onSelect({ assetId: "a", fileUrl: "https://example.com/a" });
});
expect(onSelect).toHaveBeenCalledTimes(1);
expect(lastDialogProps?.open).toBe(false);
act(() => root.unmount());
container.remove();
});
test("multiple 模式:可连续选择,不会因第一次选择而关闭", () => {
lastDialogProps = null;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onSelect = vi.fn();
act(() => {
root.render(
<ImagePickerProvider documentId="doc" workspaceId="ws">
<Harness multiple onSelect={onSelect} />
</ImagePickerProvider>,
);
});
expect(lastDialogProps?.open).toBe(true);
act(() => {
lastDialogProps?.onSelect({ assetId: "a", fileUrl: "https://example.com/a" });
});
act(() => {
lastDialogProps?.onSelect({ assetId: "b", fileUrl: "https://example.com/b" });
});
expect(onSelect).toHaveBeenCalledTimes(2);
expect(lastDialogProps?.open).toBe(true);
act(() => root.unmount());
container.remove();
});
});
@@ -8,11 +8,17 @@ interface PickerState {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
multiple: boolean;
onSelect?: (selection: MediaSelection) => void;
}
interface ImagePickerContextValue {
openPicker: (options: { onSelect: (selection: MediaSelection) => void; defaultTab?: PickerTab; mediaType?: MediaKind }) => void;
openPicker: (options: {
onSelect: (selection: MediaSelection) => void;
defaultTab?: PickerTab;
mediaType?: MediaKind;
multiple?: boolean;
}) => void;
}
const ImagePickerContext = createContext<ImagePickerContextValue | undefined>(undefined);
@@ -28,16 +34,18 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
open: false,
defaultTab: "upload",
mediaType: "image",
multiple: false,
});
const contextValue = useMemo<ImagePickerContextValue>(
() => ({
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image" }) => {
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image", multiple = false }) => {
setState({
open: true,
onSelect,
defaultTab,
mediaType,
multiple,
});
},
}),
@@ -50,7 +58,9 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
const handleSelect = (selection: MediaSelection) => {
state.onSelect?.(selection);
setState((prev) => ({ ...prev, open: false }));
if (!state.multiple) {
setState((prev) => ({ ...prev, open: false }));
}
};
return (
@@ -60,6 +70,7 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
open={state.open}
defaultTab={state.defaultTab}
mediaType={state.mediaType}
multiple={state.multiple}
documentId={documentId}
workspaceId={workspaceId}
onClose={handleClose}
@@ -16,6 +16,7 @@ interface ImagePickerDialogProps {
open: boolean;
defaultTab: PickerTab;
mediaType: MediaKind;
multiple?: boolean;
documentId: string;
workspaceId: string;
onClose: () => void;
@@ -92,6 +93,7 @@ export function ImagePickerDialog({
open,
defaultTab,
mediaType,
multiple = false,
documentId,
workspaceId,
onClose,
@@ -141,41 +143,51 @@ export function ImagePickerDialog({
}
}, [open, tab, fetchRecent]);
const handleUpload = useCallback(
const uploadOne = useCallback(
async (file: File) => {
if (!workspaceId || !documentId) {
setError("缺少必要参数");
return;
throw new Error("缺少必要参数");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
return {
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
} satisfies MediaSelection;
},
[documentId, workspaceId],
);
const handleUploadMany = useCallback(
async (files: File[]) => {
if (files.length === 0) return;
setUploading(true);
setError(null);
try {
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
for (const file of files) {
const selection = await uploadOne(file);
onSelect(selection);
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset?.file_url) {
throw new Error("返回数据缺少文件地址");
}
emitAssetsChanged(documentId, payload.asset);
onSelect({
assetId: payload.asset.id,
fileUrl: payload.asset.file_url,
thumbnailUrl: payload.asset.thumbnail_url,
assetType: payload.asset.asset_type,
fileName: payload.asset.file_name,
fileSize: payload.asset.file_size,
mimeType: payload.asset.mime_type,
});
onClose();
} catch (err) {
setError((err as Error).message);
@@ -183,15 +195,15 @@ export function ImagePickerDialog({
setUploading(false);
}
},
[documentId, onClose, onSelect, workspaceId],
[onClose, onSelect, uploadOne],
);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
void handleUpload(acceptedFiles[0]);
void handleUploadMany(multiple ? acceptedFiles : [acceptedFiles[0]]);
},
[handleUpload],
[handleUploadMany, multiple],
);
const acceptConfig = MEDIA_ACCEPTS[mediaType];
@@ -199,7 +211,7 @@ export function ImagePickerDialog({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: acceptConfig ?? undefined,
maxFiles: 1,
multiple,
});
const handleLinkSubmit = async () => {
@@ -320,7 +332,9 @@ export function ImagePickerDialog({
const label = MEDIA_TITLES[mediaType].replace("选择", "");
switch (tab) {
case "upload":
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
return multiple
? `支持拖拽或点击上传${label}(可多选),单个文件不超过 200MB`
: `支持拖拽或点击上传${label},单个文件不超过 200MB`;
case "recent":
return `最近 12 个${label},支持一键选取`;
case "link":
@@ -330,7 +344,7 @@ export function ImagePickerDialog({
default:
return "";
}
}, [mediaType, tab]);
}, [mediaType, multiple, tab]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
@@ -1,9 +1,12 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useConvexAuth, useQuery } from "convex/react";
import type { DocumentTable } from "@/types/online-table";
import { deleteOnlineTable, getDocumentTable, saveOnlineTable } from "@/lib/online-table";
import { Loader2, Table as TableIcon, Maximize2, Trash2, RotateCw } from "lucide-react";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
interface CompactTablePreviewProps {
tableId: string;
@@ -13,16 +16,45 @@ interface CompactTablePreviewProps {
}
const useTableData = (tableId: string) => {
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const tableFromConvex = useQuery(
api.tables.get,
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
);
const [table, setTable] = useState<DocumentTable | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [version, setVersion] = useState(0);
const refresh = useCallback(() => {
setIsLoading(true);
setVersion((prev) => prev + 1);
}, []);
if (!convexEnabled) {
setIsLoading(true);
setVersion((prev) => prev + 1);
}
}, [convexEnabled]);
useEffect(() => {
if (convexEnabled) {
if (tableFromConvex === undefined) {
setIsLoading(true);
return;
}
if (tableFromConvex === null) {
setTable(null);
setIsLoading(false);
return;
}
setTable({ ...(tableFromConvex as unknown as DocumentTable), title: (tableFromConvex as any)?.title || "未命名表格" });
setIsLoading(false);
return;
}
let canceled = false;
getDocumentTable(tableId)
.then((data) => {
@@ -44,7 +76,7 @@ const useTableData = (tableId: string) => {
return () => {
canceled = true;
};
}, [tableId, version]);
}, [convexEnabled, tableFromConvex, tableId, version]);
return { table, isLoading, refresh };
};
@@ -103,7 +135,7 @@ const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
}, []);
const handleDeleteTable = useCallback(async () => {
const confirmed = window.confirm("删除表格将同步移除 Supabase 记录,确认继续?");
const confirmed = window.confirm("删除表格将同步移除在线表格记录,确认继续?");
if (!confirmed) return;
try {
await deleteOnlineTable(tableId);
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, Table, X, Zap } from "lucide-react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import type { DocumentTable, TableRowData } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
@@ -10,6 +11,8 @@ import {
createDefaultTableSnapshot,
saveOnlineTable,
} from "@/lib/online-table";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { extractRowsForPreview } from "@/components/online-table/utils";
@@ -75,6 +78,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
const lastLocalPersistAtRef = useRef<number>(0);
const lastPointerDownInGridRef = useRef(false);
const savingHintTimerRef = useRef<number | null>(null);
const [showSavingHint, setShowSavingHint] = useState(false);
@@ -82,6 +86,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [renameValue, setRenameValue] = useState("");
const [isSavingTitle, setIsSavingTitle] = useState(false);
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const tableFromConvex = useQuery(
api.tables.get,
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
);
const updateTable = useMutation(api.tables.update);
const startSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) return;
savingHintTimerRef.current = window.setTimeout(() => {
@@ -129,16 +146,36 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
}, []);
useEffect(() => {
fetchTable(tableId);
hasInitializedRef.current = false;
lastTableIdRef.current = tableId;
}, [fetchTable, tableId]);
}, [tableId]);
useEffect(() => {
if (tableData) {
if (convexEnabled) {
if (tableFromConvex === undefined) {
setIsTableLoading(true);
setTableError(null);
return;
}
if (tableFromConvex === null) {
setIsTableLoading(false);
setTableError("无法加载表格数据,请稍后重试。");
setTableData(null);
return;
}
// 避免“本地保存 -> 订阅回推 -> 立刻重建 luckysheet”导致的闪烁/选区丢失
if (Date.now() - lastLocalPersistAtRef.current < 1500) {
setIsTableLoading(false);
return;
}
setTableError(null);
setTableData(tableFromConvex as unknown as DocumentTable);
setIsTableLoading(false);
return;
}
}, [tableData]);
fetchTable(tableId);
}, [convexEnabled, fetchTable, tableFromConvex, tableId]);
useEffect(() => {
if (tableData?.title !== undefined) {
@@ -185,11 +222,22 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
};
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
lastLocalPersistAtRef.current = Date.now();
if (convexEnabled && userId) {
await updateTable({
userId,
tableId,
snapshot,
rows,
schema: tableData.schema,
});
} else {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
}
setHasPendingChanges(false);
setLastSyncedAt(Date.now());
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
@@ -200,7 +248,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
stopSavingHint();
setIsSaving(false);
}
}, [luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId]);
}, [convexEnabled, luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId, updateTable, userId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot("auto");
@@ -443,6 +491,37 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const gridKey = tableData?.grid_key ?? tableId;
const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : "";
let canceled = false;
let resizeRafId: number | null = null;
const resizeTimerIds: number[] = [];
let unlockTimerId: number | null = null;
const safeResize = () => {
if (canceled) return;
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) return;
const rect = container.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return;
const instance = window.luckysheet as any;
if (!instance || typeof instance.resize !== "function") return;
try {
instance.resize();
} catch {
// 忽略:Luckysheet 内部可能处于 destroy/create 过程中
}
};
const scheduleResize = () => {
if (canceled) return;
resizeRafId = window.requestAnimationFrame(() => {
safeResize();
});
// 多次兜底:避免首次打开时样式/布局尚未完全稳定
resizeTimerIds.push(window.setTimeout(safeResize, 80));
resizeTimerIds.push(window.setTimeout(safeResize, 240));
resizeTimerIds.push(window.setTimeout(safeResize, 800));
};
const options = {
container: LUCKY_SHEET_CONTAINER_ID,
title: tableData.title ?? tableId,
@@ -476,7 +555,10 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
imageUrlHandle: (url: string) => url,
hook: {
workbookCreateAfter: () => {
if (canceled) return;
setIsTableLoading(false);
// 仅在 Luckysheet 完成 DOM 构建后再调用 resize,避免触发其内部空引用(offsetHeight of null
scheduleResize();
},
updated: () => {
if (isApplyingSnapshotRef.current) return;
@@ -515,31 +597,21 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
return;
}
// 首次加载时,资源/样式可能刚完成注入,强制触发一次 resize 让 Luckysheet 重新计算布局(避免工具栏/公式栏不显示)
requestAnimationFrame(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
setTimeout(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
}, 80);
});
// 等待首帧渲染完成再开放 updated 事件
setTimeout(() => {
unlockTimerId = window.setTimeout(() => {
isApplyingSnapshotRef.current = false;
}, 0);
const containerEl = containerRef.current;
return () => {
canceled = true;
if (resizeRafId !== null) {
cancelAnimationFrame(resizeRafId);
}
resizeTimerIds.forEach((id) => clearTimeout(id));
if (unlockTimerId !== null) {
clearTimeout(unlockTimerId);
}
if (window.luckysheet) {
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
}
@@ -570,8 +642,13 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
}
setIsSavingTitle(true);
try {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
const finalTitle = updated.title ?? nextTitle;
let finalTitle = nextTitle;
if (convexEnabled && userId) {
await updateTable({ userId, tableId, title: nextTitle });
} else {
const updated = await saveOnlineTable(tableId, { title: nextTitle });
finalTitle = updated.title ?? nextTitle;
}
setTableData((prev) => (prev ? { ...prev, title: finalTitle } : prev));
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (error) {
@@ -581,7 +658,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
setIsRenaming(false);
setIsSavingTitle(false);
}
}, [renameValue, tableData, tableId]);
}, [convexEnabled, renameValue, tableData, tableId, updateTable, userId]);
const statusText = saveError
? saveError
@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import type { DocumentTable } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
@@ -11,6 +12,8 @@ import {
getDocumentTable,
saveOnlineTable,
} from "@/lib/online-table";
import { api } from "@/lib/convex/api";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
@@ -61,16 +64,24 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
const [saveError, setSaveError] = useState<string | null>(null);
const [showSavingHint, setShowSavingHint] = useState(false);
const savingHintTimerRef = useRef<number | null>(null);
const lastRemoteSyncedAtRef = useRef<string | null>(null);
const lastSnapshotHashRef = useRef<string | null>(null);
const lastLocalPersistAtRef = useRef<number>(0);
const computeSnapshotHash = useCallback((snapshot: unknown) => {
try {
return JSON.stringify(snapshot ?? {});
} catch {
return null;
}
}, []);
const convexEnabled = isConvexEnabled();
const { isAuthenticated } = useConvexAuth();
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
const userId =
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
? String((currentUser as any)._id)
: "";
const shouldFetchTable = Boolean(convexEnabled && userId && tableId);
const tableFromConvex = useQuery(
api.tables.get,
shouldFetchTable ? { userId, tableId } : "skip",
);
const updateTable = useMutation(api.tables.update);
const allowInlineEdit = editable ?? embed;
const startSavingHint = useCallback(() => {
if (savingHintTimerRef.current !== null) return;
@@ -92,13 +103,37 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
setIsLoading(true);
setError(null);
if (convexEnabled) {
// Convex 模式下走 useQuery 实时订阅,这里仅维护与旧逻辑兼容的 loading/error 状态
if (tableFromConvex === undefined) {
// still loading
return () => {
canceled = true;
};
}
if (!canceled) {
if (tableFromConvex === null) {
setTable(null);
setError("无法加载表格数据");
} else {
// 可编辑内嵌视图:避免每次自动保存后立即重建 UI(会闪烁)。
// 我们在本组件触发保存后的短时间内,忽略来自订阅的回写更新。
if (allowInlineEdit && Date.now() - lastLocalPersistAtRef.current < 1500) {
// ignore
} else {
setTable(tableFromConvex as unknown as DocumentTable);
}
}
setIsLoading(false);
}
return () => {
canceled = true;
};
}
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
lastSnapshotHashRef.current = computeSnapshotHash(data.snapshot);
if ((data as { last_synced_at?: string }).last_synced_at) {
lastRemoteSyncedAtRef.current = (data as { last_synced_at?: string }).last_synced_at ?? null;
}
setTable(data);
}
})
@@ -118,10 +153,9 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
canceled = true;
};
}, [computeSnapshotHash, reloadVersion, tableId]);
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
const allowInlineEdit = editable ?? embed;
const focusLuckysheetEditor = useCallback(() => {
const applyFocus = () => {
const editor = document.getElementById("luckysheet-rich-text-editor");
@@ -203,12 +237,22 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
};
lastSnapshotHashRef.current = computeSnapshotHash(snapshot);
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
lastLocalPersistAtRef.current = Date.now();
if (convexEnabled && userId) {
await updateTable({
userId,
tableId,
snapshot,
rows,
schema: table.schema,
});
} else {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
}
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
@@ -217,7 +261,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
stopSavingHint();
setIsSaving(false);
}
}, [allowInlineEdit, computeSnapshotHash, startSavingHint, stopSavingHint, table, tableId]);
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
@@ -232,36 +276,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
useEffect(() => {
if (!tableId) return;
// Supabase 已移除:此处不再做实时订阅
/* Supabase subscription removed
const channel = supabaseBrowser
.channel(`table-${tableId}-live`)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "document_tables", filter: `id=eq.${tableId}` },
(payload) => {
const next = payload.new as DocumentTable | null;
if (!next) return;
const nextSynced = (next as { last_synced_at?: string }).last_synced_at ?? null;
if (nextSynced && lastRemoteSyncedAtRef.current && nextSynced <= lastRemoteSyncedAtRef.current) {
return;
}
lastRemoteSyncedAtRef.current = nextSynced;
const nextHash = computeSnapshotHash(next.snapshot);
const currentHash = lastSnapshotHashRef.current;
if (nextHash && currentHash && nextHash === currentHash) {
return; // 相同快照无需重建,避免闪烁
}
lastSnapshotHashRef.current = nextHash;
setTable(next);
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
*/
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex
}, [tableId]);
useEffect(() => {
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { clamp } from "@/lib/constants";
import { useAppPreferencesStore } from "@/store/app-preferences";
type AgentMessage = { role: "user" | "assistant"; content: string };
@@ -83,6 +84,7 @@ export function OnlyOfficeAiAgentPanel({
}: {
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
}) {
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [open, setOpen] = useState(false);
const [page, setPage] = useState<PanelPage>("chat");
@@ -91,7 +93,7 @@ export function OnlyOfficeAiAgentPanel({
const [loading, setLoading] = useState(false);
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [networkOn, setNetworkOn] = useState(true);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
const [aiModel, setAiModel] = useState<string>("");
@@ -117,6 +119,12 @@ export function OnlyOfficeAiAgentPanel({
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
try {
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
@@ -606,4 +614,3 @@ export function OnlyOfficeAiAgentPanel({
</>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import { useAppPreferencesStore } from "@/store/app-preferences";
const applyTheme = (theme: "system" | "light" | "dark") => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const prefersDark =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
const shouldDark = theme === "dark" || (theme === "system" && prefersDark);
root.classList.toggle("dark", shouldDark);
};
export function AppPreferencesHydrator() {
const hydrated = useAppPreferencesStore((s) => s.hydrated);
const hydrate = useAppPreferencesStore((s) => s.hydrate);
const theme = useAppPreferencesStore((s) => s.theme);
const setTheme = useAppPreferencesStore((s) => s.setTheme);
const setShowStructure = useAppPreferencesStore((s) => s.setShowStructure);
useEffect(() => {
hydrate();
}, [hydrate]);
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
const current = useAppPreferencesStore.getState().theme;
if (current === "system") {
applyTheme("system");
}
};
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const el = target as HTMLElement | null;
if (!el) return false;
const tag = String(el.tagName ?? "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
return Boolean(el.isContentEditable);
};
const onKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
// 保留 Wolai 的快捷键体验,但不打断输入法/编辑器输入。
return;
}
const ctrlOrMeta = event.ctrlKey || event.metaKey;
const key = String(event.key ?? "").toLowerCase();
// Wolaictrl/cmd + shift + U 显示/隐藏块结构虚线框
if (ctrlOrMeta && event.shiftKey && key === "u") {
event.preventDefault();
const { showStructure } = useAppPreferencesStore.getState();
setShowStructure(!showStructure);
return;
}
// Wolaictrl/cmd + alt/opt + G 打开/关闭 Good Night 模式
if (ctrlOrMeta && event.altKey && key === "g") {
event.preventDefault();
const current = useAppPreferencesStore.getState().theme;
setTheme(current === "dark" ? "light" : "dark");
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [setShowStructure, setTheme]);
// hydrated 仅用于触发一次 render,避免被 tree-shaking 误删
if (!hydrated) {
return null;
}
return null;
}
@@ -69,7 +69,7 @@ export function DocumentShareDialog({
const msg = e?.message ?? "加载共享者失败,请重试";
// 说明:这类报错通常是 Convex functions 没有部署到当前 backend(尤其是自托管场景)。
if (String(msg).includes("Could not find public function for 'documentShares:listByDocument'")) {
setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后重试。");
setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后重试。");
} else {
setError(msg);
}
@@ -213,11 +213,11 @@ export function DocumentShareDialog({
await loadShares();
await onChanged?.();
setError(
"已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
"已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后再设置限制。",
);
} catch {
setError(
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后刷新页面再试。",
);
}
} else {
@@ -277,11 +277,11 @@ export function DocumentShareDialog({
await loadGroupShares();
await onChanged?.();
setError(
"已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
"已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后再设置限制。",
);
} catch {
setError(
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后刷新页面再试。",
);
}
} else {
@@ -111,7 +111,9 @@ export function FileTree({
{rows.map((row, index) => {
const label = getFileTreeRowLabel(row);
const selected = selectedRowIds.has(row.rowId);
const active = row.kind !== "asset" && row.docId === activeId;
// 说明:只有“页面本身(doc/index.md)”才需要 active 高亮。
// 否则当你打开某个页面时,它下面的思维导图文件夹/附件会出现“灰色假选中”的视觉误导。
const active = (row.kind === "doc" || row.kind === "index") && row.docId === activeId;
const draggable =
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
const inDropFeedback =
+202 -29
View File
@@ -60,7 +60,7 @@ import {
} from "@/lib/file-tree/clipboard";
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
import { api } from "@/lib/convex/api";
@@ -239,6 +239,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
setTree(() => {
@@ -257,7 +259,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [sidebarData.mindmapAssets]);
useEffect(() => {
setTableAssets(sidebarData.tableAssets ?? []);
const hidden = hiddenTableIdsRef.current;
const now = Date.now();
hidden.forEach((ts, id) => {
if (now - ts > 15000) {
hidden.delete(id);
}
});
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
}, [sidebarData.tableAssets]);
useEffect(() => {
@@ -265,8 +274,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [activeId, setOpen]);
useEffect(() => {
const onSaved = () => void sidebarQuery.refetch();
const onDeleted = () => void sidebarQuery.refetch();
const onSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.delete(tableId);
}
void sidebarQuery.refetch();
};
const onDeleted = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (tableId) {
hiddenTableIdsRef.current.set(tableId, Date.now());
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
}
void sidebarQuery.refetch();
};
window.addEventListener("online-table-saved", onSaved as EventListener);
window.addEventListener("online-table-deleted", onDeleted as EventListener);
return () => {
@@ -288,7 +312,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const msg = e?.message ?? "加载共享摘要失败";
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
setShareSummaryError(
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。",
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。",
);
} else {
setShareSummaryError(msg);
@@ -328,7 +352,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
} catch (e: any) {
const msg = e?.message ?? "加载群组公开摘要失败";
if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) {
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。");
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。");
} else {
setGroupPublicError(msg);
}
@@ -463,13 +487,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const assets = [
...(sidebarData.trashedMediaAssets ?? []),
...(sidebarData.trashedMindmapAssets ?? []),
...(sidebarData.trashedTableAssets ?? []),
];
const keyword = trashSearch.trim().toLowerCase();
if (!keyword) {
return assets;
}
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
const mindmapChildrenSnapshot = useMemo(() => {
const mapping = sidebarData.mindmapAssetChildren ?? {};
@@ -703,7 +728,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
void (async () => {
@@ -1142,15 +1167,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
);
const handleDeleteAssets = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : [];
hints.forEach((hint) => {
if (!hint) return;
if (!assets.find((item) => item.id === hint.id)) {
assets.unshift(hint);
}
});
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
@@ -1231,16 +1260,32 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
const selectedAssets = assetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
const mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
const isAssetRow = (
row: FileTreeRow,
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
row.kind === "asset" || row.kind === "asset-folder";
const selectedAssetHints = Array.from(
new Map(
fileTreeRows
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
);
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = selectedAssetHints.filter(
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
).length;
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
const assetTextParts: string[] = [];
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(删除`);
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(移入垃圾桶,10 分钟内可恢复`);
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
@@ -1273,7 +1318,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
if (assetIds.length > 0) {
await handleDeleteAssets(assetIds);
await handleDeleteAssets(assetIds, selectedAssetHints);
}
await refreshTree();
@@ -1842,6 +1887,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
if (!confirmTrashAction("确认恢复该附件吗?")) {
return;
}
const assetHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
null;
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1853,8 +1902,84 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除附件时会同步移除主编辑区块;恢复后向编辑器广播“恢复事件”,由编辑器决定是否插入。
let assetForInsert = assetHint as any;
if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) {
const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim();
let fileUrl = fallback;
if (!fileUrl) {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
if (res.ok) {
const payload = (await res.json().catch(() => null)) as any;
fileUrl = String(payload?.signedUrl ?? "").trim();
}
} catch {
// ignore
}
}
if (fileUrl) {
assetForInsert = {
...(assetHint as any),
id: assetId,
file_url: fileUrl,
thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any,
} as any;
editorBridge?.insertMediaAsset?.(assetForInsert);
}
}
if (assetHint?.document_id) {
emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert });
}
},
[confirmTrashAction, refreshTree, sidebarQuery],
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedMediaAssets,
sidebarQuery,
],
);
const handleRestoreTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("确认恢复该在线表格吗?")) {
return;
}
const tableHint =
(filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ??
(sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ??
null;
const response = await fetch("/api/tables/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "恢复在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除在线表格会同步移除主编辑区块;恢复后若目标页面正打开,则把表格重新插入主编辑区。
if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) {
editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId });
}
if (tableHint?.document_id) {
emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId });
}
},
[
activeId,
confirmTrashAction,
editorBridge,
filteredTrashedMediaAssets,
refreshTree,
sidebarData.trashedTableAssets,
sidebarQuery,
],
);
const handlePurgeMediaAssetFromTrash = useCallback(
@@ -1877,6 +2002,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handlePurgeTableFromTrash = useCallback(
async (tableId: string) => {
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
return;
}
const response = await fetch("/api/tables/purge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
window.alert(payload?.error ?? "彻底删除在线表格失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
},
[confirmTrashAction, refreshTree, sidebarQuery],
);
const handleEmptyMediaTrash = useCallback(async () => {
if (!sidebarData.activeWorkspaceId) {
window.alert("暂无可清空的工作空间");
@@ -1887,7 +2032,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
setEmptyingTrash(true);
try {
const [mediaResp, mindmapResp] = await Promise.all([
const [mediaResp, mindmapResp, tableResp] = await Promise.all([
fetch("/api/media/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1898,6 +2043,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
fetch("/api/tables/empty-trash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
}),
]);
if (!mediaResp.ok) {
const payload = await mediaResp.json().catch(() => ({}));
@@ -1909,6 +2059,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
return;
}
if (!tableResp.ok) {
const payload = await tableResp.json().catch(() => ({}));
window.alert(payload?.error ?? "清空在线表格垃圾桶失败,请稍后再试");
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
} finally {
setEmptyingTrash(false);
@@ -1931,8 +2086,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return;
}
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
// 重要:删除思维导图会同步移除主编辑区块;恢复后若目标页面正打开,则把导图重新插入主编辑区。
if (documentId && String(documentId) === String(activeId)) {
editorBridge?.insertMindmapAsset?.({ documentId, mindmapId });
}
if (documentId) {
emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId });
}
},
[confirmTrashAction, refreshTree, sidebarQuery],
[activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery],
);
const handlePurgeMindmapFromTrash = useCallback(
@@ -2387,7 +2549,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
<span className="text-xs text-gray-400">
{sidebarData.trashedDocuments.length +
(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}{" "}
</span>
</button>
@@ -2509,7 +2672,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
/>
)}
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
<DrawerContent className="max-h-[90vh]">
<DrawerContent className="max-h-[90vh] overflow-hidden">
<div className="flex max-h-[90vh] flex-col">
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
{trashTab === "documents" ? (
@@ -2520,6 +2684,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</p>
)}
</DrawerHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 px-4 pb-6">
<div className="flex gap-2">
<button
@@ -2543,7 +2708,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() => setTrashTab("assets")}
>
(
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
{(sidebarData.trashedMediaAssets?.length ?? 0) +
(sidebarData.trashedMindmapAssets?.length ?? 0) +
(sidebarData.trashedTableAssets?.length ?? 0)}
)
</button>
</div>
@@ -2624,7 +2791,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
</div>
<div className="text-xs text-gray-400">
{item.mime_type ?? item.asset_type ?? "unknown"}
{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
</div>
</div>
<div className="flex gap-2">
@@ -2634,7 +2801,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() =>
void (item.asset_type === "mindmap"
? handleRestoreMindmapFromTrash(item.document_id, item.id)
: handleRestoreMediaAssetFromTrash(item.id))
: item.asset_type === "luckysheet"
? handleRestoreTableFromTrash(item.id)
: handleRestoreMediaAssetFromTrash(item.id))
}
>
@@ -2645,7 +2814,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onClick={() =>
void (item.asset_type === "mindmap"
? handlePurgeMindmapFromTrash(item.document_id, item.id)
: handlePurgeMediaAssetFromTrash(item.id))
: item.asset_type === "luckysheet"
? handlePurgeTableFromTrash(item.id)
: handlePurgeMediaAssetFromTrash(item.id))
}
>
@@ -2656,6 +2827,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
)}
</div>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</>
@@ -19,6 +19,7 @@ export interface SidebarInitialData {
trashedDocuments: TrashRecord[];
trashedMediaAssets?: MediaAsset[];
trashedMindmapAssets?: MediaAsset[];
trashedTableAssets?: MediaAsset[];
/**
* 线Luckysheet
* index / /
@@ -57,6 +57,11 @@ export function useConvexSidebarData(workspaceId: string): {
shouldFetchAuthed ? { userId, workspaceId, limit: 2000 } : "skip",
);
const tables = useQuery(
api.tables.listByWorkspaceForSearch,
shouldFetchAuthed ? { userId, workspaceId, includeArchived: true, limit: 3000 } : "skip",
);
const workspacesResult = useQuery(
api.workspaces.fetchWorkspaceSummaries,
shouldFetch ? {} : "skip",
@@ -77,6 +82,7 @@ export function useConvexSidebarData(workspaceId: string): {
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
tables === undefined ||
workspacesResult === undefined) {
return null;
}
@@ -137,6 +143,63 @@ export function useConvexSidebarData(workspaceId: string): {
};
});
const tableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => !row.is_archived)
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? workspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
const trashedTableAssets: MediaAsset[] = (tables ?? [])
.filter((row) => Boolean(row.is_archived))
.map((row) => {
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
return {
id: row.id,
workspace_id: row.workspace_id ?? workspaceId,
document_id: row.document_id,
asset_type: "luckysheet",
file_url: null,
thumbnail_url: null,
bucket: null,
storage_path: null,
file_name: fileName,
file_size: null,
mime_type: "application/json",
ocr_payload: undefined,
ocr_strategy: null,
ocr_text: null,
ocr_status: null,
deleted_at: row.deleted_at ?? row.updated_at ?? null,
deleted_by: row.deleted_by ?? null,
purged_at: row.purged_at ?? null,
signed_url: null,
created_at: row.created_at ?? "",
updated_at: row.updated_at ?? "",
};
});
return {
activeWorkspaceId: workspacesResult.activeWorkspaceId || workspaceId,
workspaces: workspacesResult.workspaces,
@@ -144,10 +207,11 @@ export function useConvexSidebarData(workspaceId: string): {
trashedDocuments,
trashedMediaAssets: ((trashedMediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
trashedMindmapAssets,
trashedTableAssets,
mindmapDocs,
mindmapAssets,
mindmapAssetChildren: {},
tableAssets: [],
tableAssets,
mediaAssets: ((mediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
};
}, [
@@ -157,6 +221,7 @@ export function useConvexSidebarData(workspaceId: string): {
mindmaps,
mediaAssets,
trashedMediaAssets,
tables,
workspacesResult,
workspaceId,
]);
@@ -170,6 +235,7 @@ export function useConvexSidebarData(workspaceId: string): {
mindmaps === undefined ||
mediaAssets === undefined ||
trashedMediaAssets === undefined ||
tables === undefined ||
workspacesResult === undefined
);
const error = null;
+11
View File
@@ -1,5 +1,6 @@
import { ConvexHttpClient } from "convex/browser";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
let cached: ConvexHttpClient | null = null;
@@ -31,6 +32,16 @@ export async function getConvexAuthedHttpClient(): Promise<ConvexHttpClient> {
const token = await convexAuthNextjsToken();
if (!token) {
// 说明:开发用户模式(MNOTE_DEV_AUTH=1)用于迁移/联调阶段的“免登录”体验。
// 此时浏览器侧可能没有 Convex Auth cookies,但服务端仍需要能访问 Convex。
// 若配置了自托管 Admin Key,则允许在开发用户模式下回退到 Admin Auth(仅本地/联调使用)。
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
if (adminKey && isDevAuthEnabled()) {
const client = new ConvexHttpClient(url);
(client as any).setAdminAuth(adminKey);
return client;
}
throw new Error("未登录");
}
+11
View File
@@ -3,6 +3,7 @@
*/
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
export const ASSETS_RESTORED_EVENT = "wolai:assets-restored";
type AssetsChangedPayload = {
docId?: string;
@@ -12,6 +13,11 @@ type AssetsChangedPayload = {
mindmapAssetIds?: string[];
};
type AssetsRestoredPayload =
| { docId: string; kind: "media"; assetId: string; asset?: unknown }
| { docId: string; kind: "mindmap"; mindmapId: string }
| { docId: string; kind: "table"; tableId: string };
export function emitAssetsChanged(
docId?: string,
asset?: unknown,
@@ -24,6 +30,11 @@ export function emitAssetsChanged(
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
}
export function emitAssetsRestored(payload: AssetsRestoredPayload) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(ASSETS_RESTORED_EVENT, { detail: payload }));
}
export function emitDocumentsChanged(docId?: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
const buildReq = () =>
new Request("https://example.com/api/test", {
headers: {
"x-forwarded-proto": "https",
"x-forwarded-host": "app.example.com",
},
});
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
describe("maybeProxyForBrowserUrl", () => {
it("对 localhost URL 生成 proxy URL", () => {
const req = buildReq();
const raw = "http://localhost:3210/api/storage/xxx";
const out = maybeProxyForBrowserUrl(req, raw);
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
expect(out).toBe(expected);
});
it("对与 Convex Origin host 匹配的 URL 生成 proxy URL", () => {
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
const req = buildReq();
const raw = "http://backend:3210/api/storage/yyy";
const out = maybeProxyForBrowserUrl(req, raw);
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
expect(out).toBe(expected);
});
it("对外部 URL 不做处理", () => {
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
const req = buildReq();
const raw = "https://cdn.example.com/image.png";
const out = maybeProxyForBrowserUrl(req, raw);
expect(out).toBe(raw);
});
it("已经是 /api/onlyoffice/proxy 的 URL 不重复包裹", () => {
const req = buildReq();
const raw = "/api/onlyoffice/proxy?u=abc";
const out = maybeProxyForBrowserUrl(req, raw);
expect(out).toBe(raw);
});
});
@@ -0,0 +1,57 @@
const base64UrlEncodeUtf8 = (input: string) => {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
};
const getRequestOrigin = (request: Request) => {
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
return `${protocol}//${host}`;
};
const isLocalHostname = (hostname: string) =>
hostname === "127.0.0.1" ||
hostname === "localhost" ||
hostname === "host.docker.internal" ||
hostname === "0.0.0.0";
const getConvexOriginHost = () => {
const raw = (process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "").trim();
if (!raw) return null;
try {
const u = new URL(raw);
return u.hostname;
} catch {
return null;
}
};
/**
* URL `/api/onlyoffice/proxy?u=...`
* URL SSRF `/api/onlyoffice/proxy`
*/
export const maybeProxyForBrowserUrl = (request: Request, rawUrl: string) => {
const input = String(rawUrl || "").trim();
if (!input) return input;
if (input.startsWith("/api/onlyoffice/proxy")) return input;
try {
const u = new URL(input);
const convexHost = getConvexOriginHost();
const shouldProxy = isLocalHostname(u.hostname) || (convexHost ? u.hostname === convexHost : false);
if (!shouldProxy) return input;
const origin = getRequestOrigin(request);
const proxy = new URL("/api/onlyoffice/proxy", origin);
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
return proxy.toString();
} catch {
return input;
}
};
+8
View File
@@ -1,4 +1,5 @@
import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
// 说明:
// - 这里启用 Convex Auth 的 Next.js 中间件,负责:
@@ -10,6 +11,9 @@ import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirec
const isPublicRoute = createRouteMatcher([
"/auth",
"/login",
// 说明:仅用于本地/联调的页面选项回归入口(Playwright 会用它验证页面选项是否真正生效)。
// 该路由不写入后端数据,放行可避免 E2E 因鉴权/数据初始化问题被阻塞。
"/dev/page-options-playground",
"/api/auth(.*)",
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
"/api/onlyoffice/proxy(.*)",
@@ -31,6 +35,10 @@ export default convexAuthNextjsMiddleware(async (request, ctx) => {
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
// 开发用户模式下允许“免登录”访问(主要用于迁移/联调与 E2E 回归)。
// 注意:生产环境请勿开启 MNOTE_DEV_AUTH。
if (isDevAuthEnabled()) return;
const authed = await ctx.convexAuth.isAuthenticated();
if (!authed) {
return nextjsMiddlewareRedirect(request, "/auth");
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { create } from "zustand";
export type ThemeMode = "system" | "light" | "dark";
type PersistedPreferences = {
theme: ThemeMode;
showStructure: boolean;
spellCheck: boolean;
flightMode: boolean;
};
const STORAGE_KEY = "mnote:preferences:v1";
const loadPersisted = (): PersistedPreferences | null => {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<PersistedPreferences>;
const theme = parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" ? parsed.theme : "system";
return {
theme,
showStructure: typeof parsed.showStructure === "boolean" ? parsed.showStructure : false,
spellCheck: typeof parsed.spellCheck === "boolean" ? parsed.spellCheck : false,
flightMode: typeof parsed.flightMode === "boolean" ? parsed.flightMode : false,
};
} catch {
return null;
}
};
const persist = (value: PersistedPreferences) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// ignore
}
};
interface AppPreferencesState extends PersistedPreferences {
hydrated: boolean;
hydrate: () => void;
setTheme: (theme: ThemeMode) => void;
setShowStructure: (showStructure: boolean) => void;
setSpellCheck: (spellCheck: boolean) => void;
setFlightMode: (flightMode: boolean) => void;
}
export const useAppPreferencesStore = create<AppPreferencesState>((set, get) => ({
hydrated: false,
theme: "system",
showStructure: false,
spellCheck: false,
flightMode: false,
hydrate: () => {
const current = get();
if (current.hydrated) return;
const persisted = loadPersisted();
if (persisted) {
set({ ...persisted, hydrated: true });
} else {
set({ hydrated: true });
}
},
setTheme: (theme) =>
set((state) => {
const next = { ...state, theme };
persist({
theme: next.theme,
showStructure: next.showStructure,
spellCheck: next.spellCheck,
flightMode: next.flightMode,
});
return next;
}),
setShowStructure: (showStructure) =>
set((state) => {
const next = { ...state, showStructure };
persist({
theme: next.theme,
showStructure: next.showStructure,
spellCheck: next.spellCheck,
flightMode: next.flightMode,
});
return next;
}),
setSpellCheck: (spellCheck) =>
set((state) => {
const next = { ...state, spellCheck };
persist({
theme: next.theme,
showStructure: next.showStructure,
spellCheck: next.spellCheck,
flightMode: next.flightMode,
});
return next;
}),
setFlightMode: (flightMode) =>
set((state) => {
const next = { ...state, flightMode };
persist({
theme: next.theme,
showStructure: next.showStructure,
spellCheck: next.spellCheck,
flightMode: next.flightMode,
});
return next;
}),
}));
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { create } from "zustand";
export type CommentTarget = {
workspaceId: string;
documentId: string;
blockId: string | null;
};
interface CommentsUiState {
open: boolean;
target: CommentTarget | null;
openForPage: (args: { workspaceId: string; documentId: string }) => void;
openForBlock: (args: { workspaceId: string; documentId: string; blockId: string }) => void;
close: () => void;
}
export const useCommentsUiStore = create<CommentsUiState>((set) => ({
open: false,
target: null,
openForPage: ({ workspaceId, documentId }) =>
set({
open: true,
target: { workspaceId, documentId, blockId: null },
}),
openForBlock: ({ workspaceId, documentId, blockId }) =>
set({
open: true,
target: { workspaceId, documentId, blockId },
}),
close: () => set({ open: false }),
}));
+13
View File
@@ -12,11 +12,24 @@ export interface EditorReferenceBridgeResult {
export interface EditorReferenceBridge {
insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult;
insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult;
undo?: () => void;
redo?: () => void;
getCursorBlockId?: () => string | null;
/**
* /
*
*/
insertMediaAsset?: (asset: MediaAsset) => void;
/**
* 使 mindmapId block.id
*
*/
insertMindmapAsset?: (args: { documentId: string; mindmapId: string }) => void;
/**
* 线luckysheet
*
*/
insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
replaceWithSnapshot: (blocks: Json) => void;
openTableFullScreen?: (tableId: string) => void;
}
+23
View File
@@ -1,3 +1,18 @@
export type PageFont = "default" | "song" | "kai";
export type PageLayoutDensity = "compact" | "normal" | "spacious";
export type BooleanPageOptionKey =
| "wideLayout"
| "smallText"
| "showHeadingNumbers"
| "showToc"
| "protectEditing"
| "showWordCount"
| "collapseBacklinks"
| "hideChildPages"
| "showBlockRefCount";
export interface PageOptionsState {
wideLayout: boolean;
smallText: boolean;
@@ -6,10 +21,18 @@ export interface PageOptionsState {
showStructure: boolean;
protectEditing: boolean;
showWordCount: boolean;
collapseBacklinks: boolean;
pageFont: PageFont;
layoutDensity: PageLayoutDensity;
hideChildPages: boolean;
showBlockRefCount: boolean;
embedDefaultBlockId: string | null;
}
export interface DocumentStats {
wordCount: number;
characterCount: number;
blockCount: number;
todoTotal: number;
todoDone: number;
}