0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
@@ -18,56 +17,37 @@ interface Props {
}
export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
const backendUrl = useMemo(() => getMnoteRuntimeConfig().backendUrl, []);
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const backendUrl = runtime.backendUrl;
const useConvex = Boolean(runtime.useConvex);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
// 说明:当前后端(FastAPI)仍使用 Supabase JWT 做鉴权;Convex 迁移阶段先不打通这一块。
if (useConvex) return;
if (!backendUrl) return;
setPending(true);
try {
const response = await fetch(`${backendUrl}/api/v1/tasks/ocr`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify({
document_id: documentId,
file_url: "https://example.com/sample.pdf",
}),
});
const data = await response.json();
if (response.ok) {
setTask(data);
}
// TODO:如需恢复该能力,请在接入真实鉴权后,将 access_token 从 AuthContext 注入到这里。
// 这里暂时保持 UI 可渲染,不发起请求。
void documentId;
} finally {
setPending(false);
}
};
useEffect(() => {
if (!backendUrl || !session?.access_token || !task?.task_id) {
if (useConvex) return;
if (!backendUrl || !task?.task_id) {
return;
}
const timer = setInterval(async () => {
const response = await fetch(`${backendUrl}/api/v1/tasks/${task.task_id}`, {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
return;
}
const data = (await response.json()) as TaskResponse;
setTask(data);
if (data.status === "completed") {
clearInterval(timer);
}
// 说明:同上,暂不轮询。
void timer;
}, 2000);
return () => clearInterval(timer);
}, [backendUrl, session?.access_token, task?.task_id]);
}, [backendUrl, task?.task_id, useConvex]);
return (
<Card className="mt-4 bg-white shadow-sm">
@@ -77,9 +57,14 @@ export function DocumentTaskPanel({ documentId }: Props) {
<div className="text-xs text-gray-500">
{task ? task.status : "未开始"} · {task ? `${task.progress}%` : "0%"}
</div>
{useConvex && (
<div className="text-xs text-gray-500">
Convex Supabase JWT
</div>
)}
{task?.message && <div className="text-xs text-gray-500">{task.message}</div>}
</div>
<Button onClick={triggerTask} disabled={pending} variant="outline">
<Button onClick={triggerTask} disabled={pending || useConvex} variant="outline">
{pending ? "触发中..." : "触发 OCR"}
</Button>
</CardContent>
@@ -0,0 +1,264 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildDocumentTree } from "@/lib/documents";
import { cn } from "@/lib/utils";
import { useDocumentSearch } from "@/hooks/use-document-search";
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
export type MoveEmbedMode = "move" | "embed";
const DEFAULT_FILTERS: DocumentSearchFilters = {
titleOnly: true,
exact: false,
onlyCurrentPage: false,
includeOcr: false,
timeRange: "any",
timeField: "updated",
};
type PickerItem =
| { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
method: "GET",
credentials: "include",
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message = payload?.error ?? "获取页面列表失败";
throw new Error(message);
}
return response.json();
}
interface MoveEmbedPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
defaultMode?: MoveEmbedMode;
modes?: MoveEmbedMode[];
allowRoot?: boolean;
excludeIds?: string[];
onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
}
export function MoveEmbedPickerDialog({
open,
onOpenChange,
workspaceId,
defaultMode = "move",
modes = ["move", "embed"],
allowRoot = true,
excludeIds = [],
onPick,
}: MoveEmbedPickerDialogProps) {
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
useEffect(() => {
if (!open) {
setQuery("");
setHighlighted(0);
return;
}
// 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。
setMode(defaultMode);
setQuery("");
setHighlighted(0);
}, [defaultMode, open]);
const payload = useMemo(() => {
if (!workspaceId) return null;
return {
workspaceId,
query,
filters: DEFAULT_FILTERS,
limit: 30,
};
}, [query, workspaceId]);
const trimmed = query.trim();
const isEmptyQuery = trimmed.length === 0;
const sidebarQuery = useQuery({
queryKey: ["move-embed-picker-sidebar", workspaceId],
queryFn: () => {
if (!workspaceId) {
throw new Error("缺少 workspaceId");
}
return fetchSidebarData(workspaceId);
},
enabled: open && Boolean(workspaceId) && isEmptyQuery,
staleTime: 30_000,
gcTime: 60_000,
});
const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery);
const items = useMemo<PickerItem[]>(() => {
const excluded = new Set(excludeIds);
const result: PickerItem[] = [];
if (allowRoot && mode === "move") {
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
}
if (isEmptyQuery) {
const docs = sidebarQuery.data?.documents ?? [];
const tree = buildDocumentTree(docs);
const flattened: Array<{ id: string; title: string; depth: number }> = [];
const walk = (nodes: ReturnType<typeof buildDocumentTree>, depth: number) => {
for (const node of nodes) {
flattened.push({ id: node.id, title: node.title ?? "无标题", depth });
if (node.children?.length) {
walk(node.children, depth + 1);
}
}
};
walk(tree, 0);
for (const item of flattened) {
if (excluded.has(item.id)) continue;
result.push({
kind: "doc",
id: item.id,
title: item.title,
depth: item.depth,
});
}
return result;
}
const rawList = data?.results?.length ? data?.results : data?.recent ?? [];
for (const r of rawList) {
if (!r || excluded.has(r.id)) continue;
result.push({
kind: "doc",
id: r.id,
title: r.title || "无标题",
subtitle: r.matchField === "recent" ? "最近打开" : undefined,
raw: r,
});
}
return result;
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
useEffect(() => {
setHighlighted(0);
}, [mode, query, open]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
const handlePick = useCallback(
async (targetId: string | null) => {
await onPick(mode, targetId);
onOpenChange(false);
},
[mode, onOpenChange, onPick],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
<DialogTitle className="sr-only"></DialogTitle>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<Tabs value={mode} onValueChange={(v) => setMode(v as MoveEmbedMode)}>
<TabsList className="w-full">
{modes.includes("move") && (
<TabsTrigger value="move" className="flex-1">
</TabsTrigger>
)}
{modes.includes("embed") && (
<TabsTrigger value="embed" className="flex-1">
</TabsTrigger>
)}
</TabsList>
<TabsContent value={mode} className="mt-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
<div
className="flex-1 overflow-y-auto"
onKeyDown={(event) => {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
}}
>
{!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<div className="py-2">
{items.map((item, idx) => (
<button
key={item.kind === "root" ? "root" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
idx === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useCallback, useEffect } from "react";
import { MoveEmbedPickerDialog } from "@/components/documents/move-embed-picker-dialog";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
export function MoveEmbedPickerHost() {
const open = useMoveEmbedPickerStore((s) => s.open);
const workspaceId = useMoveEmbedPickerStore((s) => s.workspaceId);
const defaultMode = useMoveEmbedPickerStore((s) => s.defaultMode);
const modes = useMoveEmbedPickerStore((s) => s.modes);
const allowRoot = useMoveEmbedPickerStore((s) => s.allowRoot);
const excludeIds = useMoveEmbedPickerStore((s) => s.excludeIds);
const onPick = useMoveEmbedPickerStore((s) => s.onPick);
const setWorkspaceId = useMoveEmbedPickerStore((s) => s.setWorkspaceId);
const close = useMoveEmbedPickerStore((s) => s.close);
useEffect(() => {
if (!open) return;
if (workspaceId) return;
let cancelled = false;
const run = async () => {
try {
const res = await fetch("/api/sidebar");
if (!res.ok) return;
const json = (await res.json().catch(() => null)) as { activeWorkspaceId?: string } | null;
const nextId = typeof json?.activeWorkspaceId === "string" ? json.activeWorkspaceId : null;
if (!cancelled) {
setWorkspaceId(nextId);
}
} catch {
// ignore
}
};
void run();
return () => {
cancelled = true;
};
}, [open, setWorkspaceId, workspaceId]);
const handlePick = useCallback(
async (mode: "move" | "embed", targetId: string | null) => {
if (onPick) {
await onPick(mode, targetId);
}
close();
},
[close, onPick],
);
return (
<MoveEmbedPickerDialog
open={open}
onOpenChange={(next) => {
if (!next) {
close();
}
}}
workspaceId={workspaceId}
defaultMode={defaultMode}
modes={modes}
allowRoot={allowRoot}
excludeIds={excludeIds}
onPick={handlePick}
/>
);
}
@@ -21,6 +21,7 @@ import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
@@ -777,13 +778,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
)}
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
)}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
@@ -793,6 +794,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
</div>
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */}
{fullScreenTableId && (
<FullScreenTableEditor
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { extractBlockText } from "@/lib/blocks";
type RemoteBlock = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: unknown;
};
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
export const blockReferenceBlock = createReactBlockSpec(
{
type: "blockReference",
propSchema: {
sourceDocumentId: { default: "" },
targetBlockId: { default: "" },
display: { default: "embed" },
},
content: "none",
},
() => ({
render: ({ block }) => <BlockReferenceContent block={block as any} />,
}),
)();
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
const router = useRouter();
const sourceDocumentId = block.props.sourceDocumentId;
const targetBlockId = block.props.targetBlockId;
const [remote, setRemote] = useState<RemoteBlock | null>(null);
const [textDraft, setTextDraft] = useState<string>("");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [error, setError] = useState<string>("");
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
useEffect(() => {
if (!sourceDocumentId || !targetBlockId) {
setStatus("error");
setError("引用信息不完整");
return;
}
let cancelled = false;
setStatus("loading");
setError("");
setRemote(null);
const run = async () => {
try {
const res = await fetch(
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
{ method: "GET", credentials: "include" },
);
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.error ?? "获取引用块失败");
}
const json = await res.json();
const next = (json?.block ?? null) as RemoteBlock | null;
if (!cancelled) {
setRemote(next);
if (next && isTextBlock(next)) {
setTextDraft(extractBlockText(next as any));
}
setStatus("idle");
}
} catch (e) {
if (!cancelled) {
setStatus("error");
setError(e instanceof Error ? e.message : "获取引用块失败");
}
}
};
void run();
return () => {
cancelled = true;
};
}, [sourceDocumentId, targetBlockId]);
const openSource = useCallback(() => {
if (sourceDocumentId) {
router.push(`/documents/${sourceDocumentId}`);
}
}, [router, sourceDocumentId]);
const saveText = useCallback(async () => {
if (!remote || !canEdit) return;
const nextBlock: RemoteBlock = {
...remote,
id: remote.id,
content: [{ type: "text", text: textDraft }],
};
const res = await fetch("/api/blocks/patch", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
const msg = payload?.error ?? "同步编辑失败";
if (typeof window !== "undefined") window.alert(msg);
return;
}
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
return (
<div
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
onMouseDown={(e) => {
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
e.stopPropagation();
}}
>
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
<span></span>
<span className="ml-auto flex items-center gap-2">
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
</Button>
</span>
</div>
{status === "loading" ? (
<div className="text-sm text-gray-400">...</div>
) : status === "error" ? (
<div className="text-sm text-red-600">{error}</div>
) : !remote ? (
<div className="text-sm text-gray-400"></div>
) : canEdit ? (
<div className="space-y-2">
<textarea
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
rows={3}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
/>
<div className="flex items-center gap-2">
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
</Button>
<span className="text-[11px] text-gray-400">MVP/</span>
</div>
</div>
) : (
<div className="text-sm text-gray-700">
<div className="mb-1 text-xs text-gray-400">{remote.type ?? "unknown"}</div>
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
</div>
)}
</div>
);
}
@@ -1,10 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) {
@@ -15,7 +16,7 @@ const normalizeTitle = (value?: string | null) => {
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const router = useRouter();
const supabaseBrowser = getSupabaseBrowserClient();
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const fallbackTitle = normalizeTitle(title);
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
@@ -27,6 +28,10 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
if (!pageId) {
return;
}
if (useConvex) {
// 说明:Convex 迁移阶段先不做 title 的实时订阅/拉取,直接使用 block props 里的 title。
return;
}
let cancelled = false;
const applyTitle = (nextTitle?: string | null) => {
if (!cancelled) {
@@ -36,6 +41,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
const fetchTitle = async () => {
try {
const supabaseBrowser = getSupabaseBrowserClient();
const { data } = await supabaseBrowser
.from("documents")
.select("title")
@@ -51,6 +57,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
void fetchTitle();
const supabaseBrowser = getSupabaseBrowserClient();
const channel = supabaseBrowser
.channel(`page-ref-${pageId}`)
.on(
@@ -67,7 +74,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
cancelled = true;
supabaseBrowser.removeChannel(channel);
};
}, [pageId]);
}, [pageId, useConvex]);
const navigate = () => {
if (pageId) {
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -32,6 +33,7 @@ type ConvertOption = {
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
};
const extractText = (block: Block<CustomBlockSchema>) => {
@@ -43,10 +45,11 @@ const extractText = (block: Block<CustomBlockSchema>) => {
return "未命名页面";
};
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
@@ -156,39 +159,71 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
router.refresh();
}, [block, currentDocumentId, editor, router]);
const handleMoveEmbedPick = useCallback(
async (mode: "move" | "embed", targetDocumentId: string | null) => {
if (!targetDocumentId) {
return;
}
if (mode === "embed" && targetDocumentId === currentDocumentId) {
if (typeof window !== "undefined") {
window.alert("禁止嵌入到当前页面");
}
return;
}
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceDocumentId: currentDocumentId,
blockId: block.id,
targetDocumentId,
position: "end",
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message =
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
if (typeof window !== "undefined") {
window.alert(message);
}
return;
}
if (mode === "move") {
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
router.refresh();
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入嵌入引用块");
}
},
[block.id, currentDocumentId, editor, router],
);
const moveOrEmbedBlock = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
if (!targetParent) {
return;
}
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: targetParent.trim(),
title: extractText(block),
blocks: [block],
}),
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
openPicker({
workspaceId,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: false,
excludeIds: [currentDocumentId],
onPick: handleMoveEmbedPick,
});
if (!response.ok) {
window.alert("移动失败,请确认页面 ID");
return;
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
return;
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
const convertOptions = useMemo<ConvertOption[]>(
() => [
@@ -372,6 +407,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
};
export const CustomSideMenu = (props: CustomSideMenuProps) => (
@@ -381,6 +417,7 @@ export const CustomSideMenu = (props: CustomSideMenuProps) => (
<CustomDragHandleMenu
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
currentDocumentId={props.currentDocumentId}
workspaceId={props.workspaceId}
/>
)}
/>
@@ -13,6 +13,7 @@ import { progressBlock } from "./blocks/ProgressBlock";
import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
@@ -24,6 +25,7 @@ export const customBlockSchema = BlockNoteSchema.create({
...defaultBlockSpecs,
heading: headingSpec,
pageReference: pageReferenceBlock,
blockReference: blockReferenceBlock,
advancedTodo: advancedTodoBlock,
progressMeter: progressBlock,
media: mediaBlock,
@@ -15,6 +15,7 @@ import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoad
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
type LuckysheetSelection =
| {
@@ -52,7 +53,15 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const supabaseBrowser = useMemo(() => {
if (useConvex) return null;
try {
return getSupabaseBrowserClient();
} catch {
return null;
}
}, [useConvex]);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
@@ -234,6 +243,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
useEffect(() => {
if (!tableId) return;
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel(`table-${tableId}-live`)
.on(
@@ -261,7 +271,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [tableId]);
}, [tableId, supabaseBrowser]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
@@ -48,6 +48,7 @@ import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
import {
inferPasteTargetDocId,
isTextInputTarget,
@@ -128,7 +129,15 @@ interface ContextMenuState {
export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore();
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
const supabaseBrowser = useMemo(() => {
if (useConvex) return null;
try {
return getSupabaseBrowserClient();
} catch {
return null;
}
}, [useConvex]);
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
@@ -154,6 +163,9 @@ export function Sidebar({ initialData }: SidebarProps) {
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>(sidebarData.mediaAssets ?? []);
const [mindmapAssets, setMindmapAssets] = useState<MediaAsset[]>(sidebarData.mindmapAssets ?? []);
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
@@ -242,6 +254,7 @@ export function Sidebar({ initialData }: SidebarProps) {
}, [sidebarQuery]);
useEffect(() => {
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel("documents-feed")
.on(
@@ -255,9 +268,10 @@ export function Sidebar({ initialData }: SidebarProps) {
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [refreshTree]);
}, [refreshTree, supabaseBrowser]);
useEffect(() => {
if (!supabaseBrowser) return;
const channel = supabaseBrowser
.channel("media-assets-feed")
.on(
@@ -276,7 +290,7 @@ export function Sidebar({ initialData }: SidebarProps) {
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [sidebarData.activeWorkspaceId, sidebarQuery]);
}, [sidebarData.activeWorkspaceId, sidebarQuery, supabaseBrowser]);
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
@@ -476,7 +490,19 @@ export function Sidebar({ initialData }: SidebarProps) {
[refreshTree],
);
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
setMoveEmbedSource(node);
setMoveEmbedMode(nextMode);
setMoveEmbedOpen(true);
}, []);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
openMoveEmbedPicker(node, "embed");
return;
if (sidebarData.activeWorkspaceId) {
openMoveEmbedPicker(node, "embed");
return;
}
if (typeof window === "undefined") {
return;
}
@@ -1465,6 +1491,12 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleMovePrompt = useCallback(
async (node: DocumentNode) => {
openMoveEmbedPicker(node, "move");
return;
if (sidebarData.activeWorkspaceId) {
openMoveEmbedPicker(node, "move");
return;
}
if (typeof window === "undefined") {
return;
}
@@ -2034,6 +2066,44 @@ export function Sidebar({ initialData }: SidebarProps) {
onDelete={() => void handleDeleteFileTreeSelection()}
/>
)}
<MoveEmbedPickerDialog
open={moveEmbedOpen}
onOpenChange={setMoveEmbedOpen}
workspaceId={sidebarData.activeWorkspaceId ?? null}
defaultMode={moveEmbedMode}
excludeIds={moveEmbedSource?.id ? [moveEmbedSource.id] : []}
onPick={async (pickedMode, targetId) => {
const source = moveEmbedSource;
if (!source) {
return;
}
if (pickedMode === "move") {
await handleMove(source.id, targetId, 0);
return;
}
if (!targetId) {
return;
}
if (typeof window !== "undefined" && source.id === targetId) {
window.alert("不能嵌入到自身页面");
return;
}
const response = await fetch("/api/documents/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: source.id, targetId }),
});
if (!response.ok) {
if (typeof window !== "undefined") {
window.alert("嵌入失败,请检查目标页面");
}
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入引用块");
}
}}
/>
{assetMenu && (
<AssetContextMenu
asset={assetMenu.asset}