chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
"use client";
|
||||
|
||||
import "@blocknote/core/style.css";
|
||||
import "@blocknote/react/style.css";
|
||||
import "@blocknote/mantine/style.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BlockNoteView } from "@blocknote/mantine";
|
||||
import {
|
||||
SideMenuController,
|
||||
useCreateBlockNote,
|
||||
type SideMenuProps,
|
||||
} from "@blocknote/react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import * as Y from "yjs";
|
||||
import type { Block } from "@blocknote/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { customBlockSchema, type CustomBlockSchema } from "./schema";
|
||||
import { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
initialContent: unknown;
|
||||
pageOptions: PageOptionsState;
|
||||
onStatsChange?: (stats: DocumentStats) => void;
|
||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||
}
|
||||
|
||||
const extractInitialBlocks = (content: unknown): Json | undefined => {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return content as Json;
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const maybeBlocks = (content as Record<string, unknown>).blocks;
|
||||
if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) {
|
||||
return maybeBlocks as Json;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractInlineText = (block: Block<CustomBlockSchema>): string => {
|
||||
const inlineNodes = (block.content ?? []) as Array<{ text?: string }>;
|
||||
return inlineNodes.map((node) => (typeof node.text === "string" ? node.text : "")).join("").trim();
|
||||
};
|
||||
|
||||
const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
|
||||
const counters = [0, 0, 0, 0, 0];
|
||||
const entries: TocEntry[] = [];
|
||||
|
||||
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (block.type === "heading") {
|
||||
const level = Math.min(5, Math.max(1, Number(block.props.level) || 1));
|
||||
counters[level - 1] += 1;
|
||||
for (let i = level; i < counters.length; i += 1) {
|
||||
counters[i] = 0;
|
||||
}
|
||||
const numbering = counters.slice(0, level).filter((value) => value > 0).join(".");
|
||||
entries.push({
|
||||
id: block.id,
|
||||
level,
|
||||
numbering,
|
||||
title: extractInlineText(block),
|
||||
});
|
||||
}
|
||||
if (block.children && block.children.length > 0) {
|
||||
walk(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(blocks);
|
||||
return entries;
|
||||
};
|
||||
|
||||
const findBlockById = (
|
||||
blocks: Block<CustomBlockSchema>[],
|
||||
id: string,
|
||||
): Block<CustomBlockSchema> | undefined => {
|
||||
for (const block of blocks) {
|
||||
if (block.id === id) return block;
|
||||
if (block.children && block.children.length > 0) {
|
||||
const child = findBlockById(block.children as Block<CustomBlockSchema>[], id);
|
||||
if (child) return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote>) => {
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
const blocks = editorInstance.topLevelBlocks as Block<CustomBlockSchema>[];
|
||||
const progressStats = new Map<
|
||||
string,
|
||||
{ done: number; doing: number; total: number }
|
||||
>();
|
||||
let activeProgressId: string | null = null;
|
||||
|
||||
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (block.type === "progressMeter" && block.props.auto) {
|
||||
activeProgressId = block.id;
|
||||
progressStats.set(block.id, { done: 0, doing: 0, total: 0 });
|
||||
} else if (block.type === "progressMeter" && !block.props.auto) {
|
||||
activeProgressId = null;
|
||||
} else if (block.type === "heading") {
|
||||
activeProgressId = null;
|
||||
} else if (block.type === "advancedTodo" && activeProgressId) {
|
||||
const currentStat = progressStats.get(activeProgressId);
|
||||
if (!currentStat) return;
|
||||
if (block.props.status === "cancelled") {
|
||||
return;
|
||||
}
|
||||
currentStat.total += 1;
|
||||
if (block.props.status === "done") {
|
||||
currentStat.done += 1;
|
||||
} else if (block.props.status === "doing") {
|
||||
currentStat.doing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (block.children && block.children.length > 0) {
|
||||
traverse(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
traverse(blocks);
|
||||
|
||||
progressStats.forEach((stat, progressId) => {
|
||||
const block = findBlockById(blocks, progressId);
|
||||
if (!block) return;
|
||||
const weightedDone = stat.done + stat.doing * 0.5;
|
||||
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
|
||||
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
|
||||
|
||||
if (block.props.percent !== percent || block.props.summary !== summary) {
|
||||
editorInstance.updateBlock(block, {
|
||||
props: {
|
||||
percent,
|
||||
summary,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function BlockNoteEditor({
|
||||
documentId,
|
||||
workspaceId,
|
||||
initialContent,
|
||||
pageOptions,
|
||||
onStatsChange,
|
||||
onSnapshot,
|
||||
}: BlockNoteEditorProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
|
||||
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
||||
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
|
||||
const normalizedInitialContent = useMemo(
|
||||
() => extractInitialBlocks(initialContent),
|
||||
[initialContent],
|
||||
);
|
||||
|
||||
const collaboration = useMemo(() => {
|
||||
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
|
||||
if (!url) return null;
|
||||
const doc = new Y.Doc();
|
||||
const provider = new HocuspocusProvider({
|
||||
url,
|
||||
name: `document.${documentId}`,
|
||||
document: doc,
|
||||
});
|
||||
return { doc, provider };
|
||||
}, [documentId]);
|
||||
|
||||
const editor = useCreateBlockNote(
|
||||
{
|
||||
initialContent: normalizedInitialContent as never,
|
||||
schema: customBlockSchema,
|
||||
collaboration: collaboration
|
||||
? {
|
||||
provider: collaboration.provider,
|
||||
fragment: collaboration.doc.getXmlFragment("wolai"),
|
||||
user: {
|
||||
name: "访客",
|
||||
color: "#2563eb",
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
collaboration?.provider.destroy();
|
||||
collaboration?.doc.destroy();
|
||||
},
|
||||
[collaboration],
|
||||
);
|
||||
|
||||
const saveContent = useCallback(
|
||||
async (content: Json) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await fetch("/api/documents/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, content }),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const runSync = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const blocks = editor.topLevelBlocks;
|
||||
debouncedSave(blocks as Json);
|
||||
const typedBlocks = blocks as Block<CustomBlockSchema>[];
|
||||
setTocEntries(buildHeadingToc(typedBlocks));
|
||||
syncProgressMeters(editor);
|
||||
const stats = computeDocumentStats(typedBlocks);
|
||||
onStatsChange?.(stats);
|
||||
onSnapshot?.({ blocks: blocks as Json, stats });
|
||||
};
|
||||
|
||||
runSync();
|
||||
const unsubscribe = editor.onEditorContentChange(runSync);
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editorWrapperClass = cn(
|
||||
"relative min-h-[60vh] rounded-2xl border border-transparent bg-white p-4 shadow-sm",
|
||||
pageOptions.smallText ? "text-[15px]" : "text-[16px]",
|
||||
);
|
||||
|
||||
const blocknoteClass = cn(
|
||||
"wolai-editor min-h-full",
|
||||
pageOptions.showStructure && "wolai-editor-show-structure",
|
||||
);
|
||||
|
||||
const buildDocumentPath = (documentId: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/documents/${documentId}`;
|
||||
}
|
||||
return `${window.location.origin}/documents/${documentId}`;
|
||||
};
|
||||
|
||||
const trimTrailingCharacter = (
|
||||
editorInstance: ReturnType<typeof useCreateBlockNote> | null,
|
||||
block: Block<CustomBlockSchema>,
|
||||
char: string,
|
||||
) => {
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
const content = Array.isArray(block.content) ? [...block.content] : [];
|
||||
for (let index = content.length - 1; index >= 0; index -= 1) {
|
||||
const node = content[index] as { text?: string };
|
||||
if (typeof node?.text === "string" && node.text.endsWith(char)) {
|
||||
const nextText = node.text.slice(0, -1);
|
||||
if (nextText.length === 0) {
|
||||
content.splice(index, 1);
|
||||
} else {
|
||||
content[index] = { ...node, text: nextText };
|
||||
}
|
||||
editorInstance.updateBlock(block, { content });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateBlockId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `ref_${Math.random().toString(36).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
|
||||
let characterCount = 0;
|
||||
let wordCount = 0;
|
||||
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (Array.isArray(block.content)) {
|
||||
block.content.forEach((node: { text?: string }) => {
|
||||
if (typeof node.text === "string") {
|
||||
const text = node.text;
|
||||
characterCount += text.length;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length > 1) {
|
||||
wordCount += tokens.length;
|
||||
} else {
|
||||
wordCount += trimmed.replace(/\s+/g, "").length;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (block.children && block.children.length > 0) {
|
||||
accumulate(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
accumulate(blocks);
|
||||
return {
|
||||
wordCount,
|
||||
characterCount,
|
||||
blockCount: blocks.length,
|
||||
};
|
||||
};
|
||||
|
||||
const insertMediaAssetBlock = useCallback(
|
||||
(asset: MediaAsset) => {
|
||||
if (!editor) {
|
||||
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);
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
}
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "media",
|
||||
props: {
|
||||
fileUrl,
|
||||
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
|
||||
assetId: asset.id,
|
||||
assetType: asset.asset_type ?? "image",
|
||||
fileName: asset.file_name ?? "",
|
||||
fileSize: asset.file_size ?? null,
|
||||
mimeType: asset.mime_type ?? "",
|
||||
ocrStatus: asset.ocr_status ?? "idle",
|
||||
},
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const uploadClipboardMedia = useCallback(
|
||||
async (file: File) => {
|
||||
if (!workspaceId) {
|
||||
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) {
|
||||
throw new Error("上传返回数据缺失");
|
||||
}
|
||||
return payload.asset;
|
||||
},
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
registerEditorBridge(null);
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const blockId = cursor?.block?.id ?? null;
|
||||
const text = aliasText || target.title || "无标题";
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: "link",
|
||||
href: buildDocumentPath(target.id),
|
||||
content: text,
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
]);
|
||||
return { blockId };
|
||||
},
|
||||
insertEmbedReference: (target: ReferenceTarget) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock =
|
||||
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
|
||||
const blockId = generateBlockId();
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
id: blockId,
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: target.id,
|
||||
title: target.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
return { blockId };
|
||||
},
|
||||
replaceWithSnapshot: (payload: Json) => {
|
||||
editor.focus();
|
||||
const nextBlocks = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as { blocks?: Json }).blocks)
|
||||
? ((payload as { blocks?: Json }).blocks as Json)
|
||||
: [];
|
||||
if (!Array.isArray(nextBlocks)) {
|
||||
return;
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, registerEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const activeElement = event.target;
|
||||
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
||||
return;
|
||||
}
|
||||
const items = Array.from(event.clipboardData?.files ?? []);
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
const imageFile = items.find((candidate) => candidate.type?.startsWith("image/"));
|
||||
if (!imageFile) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
try {
|
||||
const asset = await uploadClipboardMedia(imageFile);
|
||||
insertMediaAssetBlock(asset);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
window.alert((error as Error).message ?? "粘贴图片失败,请稍后重试");
|
||||
}
|
||||
})();
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const buffer = { char: "", blockId: "", timestamp: 0 };
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "[" && event.key !== "#") {
|
||||
buffer.char = "";
|
||||
buffer.blockId = "";
|
||||
buffer.timestamp = 0;
|
||||
return;
|
||||
}
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
||||
return;
|
||||
}
|
||||
const { block } = editor.getTextCursorPosition();
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
buffer.char === event.key &&
|
||||
buffer.blockId === block.id &&
|
||||
now - buffer.timestamp < 450
|
||||
) {
|
||||
event.preventDefault();
|
||||
trimTrailingCharacter(editor, block, event.key);
|
||||
openReferencePalette({
|
||||
referenceMode: event.key === "[" ? "inline" : "embed",
|
||||
});
|
||||
buffer.char = "";
|
||||
buffer.blockId = "";
|
||||
buffer.timestamp = 0;
|
||||
} else {
|
||||
buffer.char = event.key;
|
||||
buffer.blockId = block.id;
|
||||
buffer.timestamp = now;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [editor, openReferencePalette]);
|
||||
|
||||
const layoutClass = cn(
|
||||
"relative mx-auto w-full",
|
||||
pageOptions.wideLayout ? "max-w-none" : "max-w-[980px]",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={layoutClass}>
|
||||
<div className={editorWrapperClass}>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
editable={!pageOptions.protectEditing}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} />
|
||||
)}
|
||||
/>
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
</BlockNoteView>
|
||||
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
||||
{isSaving ? "保存中..." : "已保存"}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
|
||||
const TODO_STATES = ["todo", "doing", "done", "cancelled"] as const;
|
||||
|
||||
const STATUS_LABELS: Record<(typeof TODO_STATES)[number], string> = {
|
||||
todo: "未开始",
|
||||
doing: "进行中",
|
||||
done: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export const advancedTodoBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "advancedTodo",
|
||||
propSchema: {
|
||||
status: {
|
||||
default: "todo",
|
||||
values: TODO_STATES,
|
||||
},
|
||||
},
|
||||
content: "inline",
|
||||
},
|
||||
{
|
||||
render: ({ block, editor }) => {
|
||||
const updateStatus = (next: (typeof TODO_STATES)[number]) => {
|
||||
editor.updateBlock(block, { props: { status: next } });
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const current = block.props.status as (typeof TODO_STATES)[number];
|
||||
if (event.altKey) {
|
||||
updateStatus("cancelled");
|
||||
return;
|
||||
}
|
||||
const index = TODO_STATES.indexOf(current);
|
||||
const nextState = TODO_STATES[(index + 1) % TODO_STATES.length];
|
||||
updateStatus(nextState);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wolai-advanced-todo">
|
||||
<button
|
||||
type="button"
|
||||
className={`wolai-advanced-todo__status status-${block.props.status}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{STATUS_LABELS[block.props.status as (typeof TODO_STATES)[number]]}
|
||||
</button>
|
||||
<div className="wolai-advanced-todo__content" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
)();
|
||||
@@ -0,0 +1,498 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
type JSX,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Type } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind } from "@/types/media";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema>;
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
file: "文件",
|
||||
};
|
||||
|
||||
const deriveFileName = (value?: string) => {
|
||||
if (!value) {
|
||||
return "未命名资源";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const last = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
const segments = value.split("?")[0]?.split("/") ?? [];
|
||||
const last = segments.pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
}
|
||||
return "未命名资源";
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number | null) => {
|
||||
if (!size || size <= 0) {
|
||||
return "未知大小";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let idx = 0;
|
||||
let current = size;
|
||||
while (current >= 1024 && idx < units.length - 1) {
|
||||
current /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileUrl = block.props.fileUrl as string;
|
||||
const rawAssetType = (block.props.assetType as string) || "image";
|
||||
const assetType: MediaKind =
|
||||
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
|
||||
? (rawAssetType as MediaKind)
|
||||
: "image";
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
const canResize = assetType === "image" || assetType === "video";
|
||||
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
|
||||
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestWidthRef = useRef(localWidth);
|
||||
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
|
||||
const captionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [captionEditing, setCaptionEditing] = useState(false);
|
||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||
|
||||
const handleChoose = () => {
|
||||
openPicker({
|
||||
defaultTab: fileUrl ? "recent" : "upload",
|
||||
mediaType: assetType,
|
||||
onSelect: (selection) => {
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? rawAssetType,
|
||||
fileName: selection.fileName ?? block.props.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBorder = () => {
|
||||
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
|
||||
};
|
||||
|
||||
const setAlign = (align: MediaAlign) => {
|
||||
editor.updateBlock(block, { props: { captionAlign: align } });
|
||||
};
|
||||
|
||||
const handleCaptionChange = (value: string) => {
|
||||
editor.updateBlock(block, { props: { caption: value } });
|
||||
};
|
||||
|
||||
const enableCaptionEdit = () => {
|
||||
setCaptionEditing(true);
|
||||
setTimeout(() => captionRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowCaption && captionEditing) {
|
||||
setCaptionEditing(false);
|
||||
}
|
||||
}, [captionEditing, shouldShowCaption]);
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
|
||||
}
|
||||
}, [block.props.width, dragging]);
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const resolvedWidth = useMemo(() => {
|
||||
if (!canResize) return 0;
|
||||
if (localWidth > 0) return clampWidth(localWidth);
|
||||
if (block.props.width && Number(block.props.width) > 0) {
|
||||
return clampWidth(Number(block.props.width));
|
||||
}
|
||||
return 0;
|
||||
}, [block.props.width, canResize, localWidth]);
|
||||
|
||||
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
|
||||
if (!canResize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
|
||||
if (!canvasWidth) {
|
||||
return;
|
||||
}
|
||||
setDragging({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
startWidth: canvasWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return undefined;
|
||||
}
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const delta = event.clientX - dragging.startX;
|
||||
const adjusted = dragging.side === "left" ? -delta : delta;
|
||||
const next = clampWidth(dragging.startWidth + adjusted);
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const handleUp = () => {
|
||||
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
|
||||
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragging, editor, block]);
|
||||
|
||||
const handleLink = () => {
|
||||
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
|
||||
if (next === null) return;
|
||||
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
|
||||
};
|
||||
|
||||
const viewOriginal = () => {
|
||||
if (!fileUrl) return;
|
||||
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
if (!fileUrl) return;
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
const triggerOcr = async () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("请先上传图片后再执行 OCR");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/media/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId: block.props.assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "触发 OCR 失败");
|
||||
}
|
||||
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
|
||||
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="wolai-media wolai-media--empty">
|
||||
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
选择或上传{typeLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">支持上传、最近及外链插入</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPreviewContent = () => {
|
||||
if (assetType === "video") {
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
className="max-h-[420px] w-full rounded-2xl bg-black"
|
||||
poster={block.props.thumbnailUrl || undefined}
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
>
|
||||
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
if (assetType === "audio") {
|
||||
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"} />
|
||||
</audio>
|
||||
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (assetType === "file") {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<span className="rounded-full bg-[#2563eb]/10 p-3 text-[#2563eb]">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-800">{displayFileName}</p>
|
||||
{block.props.fileSize ? (
|
||||
<p className="text-xs text-gray-500">{formatFileSize(block.props.fileSize)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
};
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
|
||||
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
|
||||
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
|
||||
)}
|
||||
>
|
||||
<div className="wolai-media__preview">{renderPreviewContent()}</div>
|
||||
{shouldShowCaption && (
|
||||
<figcaption>
|
||||
<input
|
||||
ref={captionRef}
|
||||
value={block.props.caption ?? ""}
|
||||
onChange={(event) => handleCaptionChange(event.target.value)}
|
||||
onBlur={() => setCaptionEditing(false)}
|
||||
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
|
||||
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
|
||||
/>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
|
||||
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
key: "replace",
|
||||
label: `替换${typeLabel}`,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
label: block.props.hasBorder ? "取消边框" : "显示边框",
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
onClick: toggleBorder,
|
||||
}
|
||||
: null,
|
||||
!shouldShowCaption
|
||||
? {
|
||||
key: "caption",
|
||||
label: "添加说明",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
onClick: enableCaptionEdit,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "link",
|
||||
label: block.props.linkUrl ? "编辑链接" : "添加链接",
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
{
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: downloadAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
return (
|
||||
<div className="wolai-media" ref={mediaRef}>
|
||||
<div className="wolai-media__canvas" style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
<div className="wolai-media__quickbar">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
type="button"
|
||||
className="wolai-media__quickbutton"
|
||||
onClick={action.onClick}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && (
|
||||
<DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>
|
||||
)}
|
||||
{canToggleBorder && (
|
||||
<DropdownMenuItem onClick={toggleBorder}>
|
||||
{block.props.hasBorder ? "取消边框" : "显示边框"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAlign && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-gray-400">说明对齐</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setAlign("left")}>左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("center")}>居中</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("right")}>右对齐</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={viewOriginal}>查看原文件</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={downloadAsset}>下载到本地</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
||||
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{canResize && (
|
||||
<>
|
||||
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
||||
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "media",
|
||||
propSchema: {
|
||||
fileUrl: { default: "", type: "string" },
|
||||
thumbnailUrl: { default: "", type: "string" },
|
||||
caption: { default: "", type: "string" },
|
||||
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
|
||||
hasBorder: { default: true, type: "boolean" },
|
||||
linkUrl: { default: "", type: "string" },
|
||||
assetId: { default: "", type: "string" },
|
||||
assetType: { default: "image", type: "string" },
|
||||
fileName: { default: "", type: "string" },
|
||||
fileSize: { default: 0, type: "number" },
|
||||
mimeType: { default: "", type: "string" },
|
||||
width: { default: 0, type: "number" },
|
||||
ocrStatus: { default: "idle", type: "string" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => <MediaBlockContent {...props} />,
|
||||
},
|
||||
)();
|
||||
const handleCopyLink = async (targetUrl: string | null) => {
|
||||
if (!targetUrl) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetUrl);
|
||||
window.alert("链接已复制");
|
||||
} else {
|
||||
throw new Error("no clipboard");
|
||||
}
|
||||
} catch {
|
||||
window.prompt("请复制以下链接", targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const ResizeHandle = ({
|
||||
side,
|
||||
onMouseDown,
|
||||
dragging,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
dragging: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
|
||||
}) => (
|
||||
<span
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
|
||||
/>
|
||||
);
|
||||
|
||||
const clampWidth = (value: number) => {
|
||||
const min = 240;
|
||||
const max = 960;
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import { RiFileTextFill } from "react-icons/ri";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
|
||||
const normalizeTitle = (value?: string | null) => {
|
||||
if (!value || !value.trim()) {
|
||||
return "未命名页面";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||
const router = useRouter();
|
||||
const fallbackTitle = normalizeTitle(title);
|
||||
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedTitle(fallbackTitle);
|
||||
}, [fallbackTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageId) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const applyTitle = (nextTitle?: string | null) => {
|
||||
if (!cancelled) {
|
||||
setResolvedTitle(normalizeTitle(nextTitle));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTitle = async () => {
|
||||
try {
|
||||
const { data } = await supabaseBrowser
|
||||
.from("documents")
|
||||
.select("title")
|
||||
.eq("id", pageId)
|
||||
.single();
|
||||
if (data) {
|
||||
applyTitle(data.title);
|
||||
}
|
||||
} catch {
|
||||
// ignore fetch errors,等待后续订阅同步
|
||||
}
|
||||
};
|
||||
|
||||
void fetchTitle();
|
||||
|
||||
const channel = supabaseBrowser
|
||||
.channel(`page-ref-${pageId}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "UPDATE", schema: "public", table: "documents", filter: `id=eq.${pageId}` },
|
||||
(payload) => {
|
||||
const nextTitle = (payload.new as { title?: string } | null)?.title ?? null;
|
||||
applyTitle(nextTitle);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [pageId]);
|
||||
|
||||
const navigate = () => {
|
||||
if (pageId) {
|
||||
router.push(`/documents/${pageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={navigate}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key === "Enter" || event.key === " ") && pageId) {
|
||||
event.preventDefault();
|
||||
navigate();
|
||||
}
|
||||
}}
|
||||
className="group mt-2 flex items-center gap-3 rounded-[4px] px-4 py-3 text-[#2563eb] hover:bg-[#f5f5f5]"
|
||||
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
|
||||
>
|
||||
<RiFileTextFill className="text-xl" aria-hidden />
|
||||
<span className="text-base font-medium leading-none">{resolvedTitle}</span>
|
||||
<span className="ml-auto text-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
点击进入 →
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const pageReferenceBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "pageReference",
|
||||
propSchema: {
|
||||
pageId: { default: "" },
|
||||
title: { default: "未命名页面" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => (
|
||||
<PageReferenceContent pageId={block.props.pageId} title={block.props.title} />
|
||||
),
|
||||
}),
|
||||
)();
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
|
||||
export const progressBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "progressMeter",
|
||||
propSchema: {
|
||||
percent: { default: 0, type: "number" },
|
||||
auto: { default: true, type: "boolean" },
|
||||
summary: { default: "", type: "string" },
|
||||
},
|
||||
content: "inline",
|
||||
},
|
||||
{
|
||||
render: ({ block, editor }) => {
|
||||
const percent = block.props.percent ?? 0;
|
||||
const handleToggle = () => {
|
||||
editor.updateBlock(block, { props: { auto: !block.props.auto } });
|
||||
};
|
||||
|
||||
const handleBarClick = () => {
|
||||
if (block.props.auto) return;
|
||||
const input = window.prompt("设置进度(0-100)", percent.toString());
|
||||
if (!input) return;
|
||||
const value = Number.parseInt(input, 10);
|
||||
if (Number.isNaN(value)) return;
|
||||
editor.updateBlock(block, {
|
||||
props: { percent: Math.min(100, Math.max(0, value)) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wolai-progress">
|
||||
<div className="wolai-progress__header">
|
||||
<span className="wolai-progress__summary">{block.props.summary || "暂无条目"}</span>
|
||||
<button type="button" className="wolai-progress__mode" onClick={handleToggle}>
|
||||
{block.props.auto ? "自动" : "手动"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="wolai-progress__bar" onClick={handleBarClick}>
|
||||
<div className="wolai-progress__fill" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="wolai-progress__percent">{percent}%</span>
|
||||
<div className="wolai-progress__description" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
)();
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } 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";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export interface DocumentContentProps {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title: string | null;
|
||||
updatedAt: string | null;
|
||||
initialContent: unknown;
|
||||
initialOptions: PageOptionsState;
|
||||
initialStats: DocumentStats | null;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
};
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
|
||||
|
||||
export function DocumentContent({
|
||||
documentId,
|
||||
workspaceId,
|
||||
title,
|
||||
updatedAt,
|
||||
initialContent,
|
||||
initialOptions,
|
||||
initialStats,
|
||||
}: DocumentContentProps) {
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setPageTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(initialOptions ?? defaultOptions);
|
||||
}, [initialOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setStats(initialStats ?? defaultStats);
|
||||
}, [initialStats]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
const payload = nextTitle.trim() || "无标题";
|
||||
await fetch("/api/documents/title", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, title: payload }),
|
||||
});
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||
void persistTitle(value);
|
||||
}, 600);
|
||||
|
||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setPageTitle(value);
|
||||
debouncedPersistTitle(value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
void persistTitle(pageTitle);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const persistOptions = useCallback(
|
||||
async (patch: Partial<PageOptionsState>) => {
|
||||
try {
|
||||
const response = await fetch("/api/documents/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, options: patch }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
console.error(payload?.error ?? "更新页面选项失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
const toggleOption = (key: keyof PageOptionsState) => {
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const formattedUpdatedAt = useMemo(() => {
|
||||
if (!updatedAt) return "";
|
||||
return new Date(updatedAt).toLocaleString();
|
||||
}, [updatedAt]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const latest = history[0];
|
||||
if (!latest) {
|
||||
window.alert("暂无可导出的内容");
|
||||
return;
|
||||
}
|
||||
const payload = JSON.stringify(latest.blocks, null, 2);
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [history, title]);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
setHistory((prev) => {
|
||||
const now = Date.now();
|
||||
if (prev.length > 0 && now - prev[0].timestamp < 4000) {
|
||||
return prev;
|
||||
}
|
||||
const snapshot: DocumentSnapshot = {
|
||||
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: now,
|
||||
blocks: payload.blocks,
|
||||
stats: payload.stats,
|
||||
};
|
||||
return [snapshot, ...prev].slice(0, 15);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const persistStatsRequest = useCallback((next: DocumentStats) => {
|
||||
void fetch("/api/documents/stats", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, stats: next }),
|
||||
}).catch((error) => console.error(error));
|
||||
}, [documentId]);
|
||||
|
||||
const persistStats = useDebouncedCallback(persistStatsRequest, 1500);
|
||||
|
||||
const handleStatsChange = useCallback(
|
||||
(nextStats: DocumentStats) => {
|
||||
setStats(nextStats);
|
||||
persistStats(nextStats);
|
||||
},
|
||||
[persistStats],
|
||||
);
|
||||
|
||||
const handleRestoreSnapshot = useCallback(
|
||||
(snapshot: DocumentSnapshot) => {
|
||||
if (!editorBridge) {
|
||||
window.alert("编辑器尚未准备好,无法恢复历史版本");
|
||||
return;
|
||||
}
|
||||
editorBridge.replaceWithSnapshot(snapshot.blocks);
|
||||
setHistoryOpen(false);
|
||||
},
|
||||
[editorBridge],
|
||||
);
|
||||
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-white">
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-[#f5f5f5] px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={pageTitle}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="无标题"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-[#333333] outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing}
|
||||
/>
|
||||
</div>
|
||||
{options.protectEditing && (
|
||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-400">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent}
|
||||
pageOptions={options}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
/>
|
||||
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
|
||||
</div>
|
||||
</div>
|
||||
{showInspector && (
|
||||
<PageOptionsSidebar
|
||||
documentId={documentId}
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onExport={handleExport}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DocumentHistoryDrawer
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
history={history}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
|
||||
interface DocumentHistoryDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
history: DocumentSnapshot[];
|
||||
onRestore: (snapshot: DocumentSnapshot) => void;
|
||||
}
|
||||
|
||||
export function DocumentHistoryDrawer({ open, onOpenChange, history, onRestore }: DocumentHistoryDrawerProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>页面历史</DrawerTitle>
|
||||
<DrawerDescription>最近保存的 15 个版本,可以一键恢复。</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="space-y-3 px-4 pb-6">
|
||||
{history.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
|
||||
尚未产生历史快照,编辑后会自动生成。
|
||||
</div>
|
||||
) : (
|
||||
history.map((snapshot) => (
|
||||
<div
|
||||
key={snapshot.id}
|
||||
className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">
|
||||
{new Date(snapshot.timestamp).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
字数 {snapshot.stats.wordCount} · 字符 {snapshot.stats.characterCount}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => onRestore(snapshot)}>
|
||||
恢复该版本
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DocumentContentProps } from "@/components/editor/document-content";
|
||||
|
||||
const DocumentContent = dynamic(
|
||||
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
|
||||
正在载入编辑器...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function DocumentShell(props: DocumentContentProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
|
||||
正在载入编辑器...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocumentContent {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TocEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
numbering: string;
|
||||
}
|
||||
|
||||
interface DocumentTocProps {
|
||||
entries: TocEntry[];
|
||||
visible: boolean;
|
||||
onJump: (id: string) => void;
|
||||
}
|
||||
|
||||
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
|
||||
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>
|
||||
<ul className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
onClick={() => onJump(entry.id)}
|
||||
>
|
||||
<span className="mr-2 font-mono text-[10px] text-gray-400">{entry.numbering}</span>
|
||||
{entry.title || "未命名"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Block, PartialBlock } from "@blocknote/core";
|
||||
import {
|
||||
BlockColorsItem,
|
||||
SideMenu,
|
||||
TableColumnHeaderItem,
|
||||
TableRowHeaderItem,
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
type DragHandleMenuProps,
|
||||
type SideMenuProps,
|
||||
} from "@blocknote/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
typeof TableRowHeaderItem
|
||||
>[0]["block"];
|
||||
type DraftBlock = PartialBlock<CustomBlockSchema> & { id?: string };
|
||||
type ConvertOption = {
|
||||
label: string;
|
||||
type?: Block<CustomBlockSchema>["type"];
|
||||
props?: Record<string, unknown>;
|
||||
shortcut?: string;
|
||||
action?: () => void;
|
||||
};
|
||||
|
||||
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
||||
currentDocumentId: string;
|
||||
};
|
||||
|
||||
const extractText = (block: Block<CustomBlockSchema>) => {
|
||||
const inlineNodes = block.content as InlineNode[] | undefined;
|
||||
const maybeText = inlineNodes?.[0]?.text;
|
||||
if (typeof maybeText === "string" && maybeText.trim().length > 0) {
|
||||
return maybeText.trim();
|
||||
}
|
||||
return "未命名页面";
|
||||
};
|
||||
|
||||
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||||
const router = useRouter();
|
||||
|
||||
const duplicateBlock = useCallback(() => {
|
||||
const blockWithoutId: DraftBlock = { ...block };
|
||||
delete blockWithoutId.id;
|
||||
editor.insertBlocks([blockWithoutId], block, "after");
|
||||
}, [block, editor]);
|
||||
|
||||
const removePageReference = useCallback(async () => {
|
||||
if (block.type === "pageReference") {
|
||||
const pageId = block.props.pageId;
|
||||
if (pageId) {
|
||||
await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId: pageId }),
|
||||
});
|
||||
}
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
router.refresh();
|
||||
}, [block, editor, router]);
|
||||
|
||||
const handleDeleteBlock = useCallback(() => {
|
||||
if (block.type === "pageReference") {
|
||||
void removePageReference();
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [block, editor, removePageReference]);
|
||||
|
||||
const turnToPage = useCallback(async () => {
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parentId: currentDocumentId,
|
||||
title: extractText(block),
|
||||
blocks: [block],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
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]);
|
||||
|
||||
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],
|
||||
}),
|
||||
});
|
||||
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]);
|
||||
|
||||
const convertOptions = useMemo<ConvertOption[]>(
|
||||
() => [
|
||||
{ label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" },
|
||||
{ label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" },
|
||||
{ label: "高级待办列表", type: "advancedTodo" },
|
||||
{ label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" },
|
||||
{ label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" },
|
||||
{ label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" },
|
||||
{ label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" },
|
||||
{ label: "页面", action: turnToPage },
|
||||
{ label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" },
|
||||
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
|
||||
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
|
||||
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
|
||||
{ label: "引述文字", type: "blockquote" },
|
||||
{ label: "代码片段", type: "codeBlock" },
|
||||
],
|
||||
[turnToPage],
|
||||
);
|
||||
|
||||
const convertBlock = useCallback(
|
||||
(option: ConvertOption) => {
|
||||
if (option.action) {
|
||||
option.action();
|
||||
return;
|
||||
}
|
||||
if (!option.type) return;
|
||||
editor.updateBlock(block, {
|
||||
type: option.type,
|
||||
props: option.props ?? {},
|
||||
});
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
const copyBlockLink = useCallback(async () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
window.alert("块链接已复制");
|
||||
} catch {
|
||||
window.prompt("复制失败,请手动复制", url);
|
||||
}
|
||||
}, [block.id, currentDocumentId]);
|
||||
|
||||
const openOnRight = useCallback(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, [block.id, currentDocumentId]);
|
||||
|
||||
const setAdvancedTodoStatus = useCallback(
|
||||
(status: "todo" | "doing" | "done" | "cancelled") => {
|
||||
if (block.type !== "advancedTodo") return;
|
||||
editor.updateBlock(block, {
|
||||
props: { status },
|
||||
});
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
const toggleProgressMode = useCallback(() => {
|
||||
if (block.type !== "progressMeter") return;
|
||||
editor.updateBlock(block, {
|
||||
props: { auto: !block.props.auto },
|
||||
});
|
||||
}, [block, editor]);
|
||||
|
||||
const setManualProgress = useCallback(() => {
|
||||
if (block.type !== "progressMeter") return;
|
||||
const value = Number.parseInt(window.prompt("手动设置进度(0-100)", String(block.props.percent ?? 0)) ?? "", 10);
|
||||
if (Number.isNaN(value)) return;
|
||||
const clamped = Math.min(100, Math.max(0, value));
|
||||
editor.updateBlock(block, {
|
||||
props: { percent: clamped },
|
||||
});
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={openOnRight}>
|
||||
在右侧边栏打开
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Root sub>
|
||||
<Components.Generic.Menu.Trigger sub>
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" subTrigger>
|
||||
转换为
|
||||
</Components.Generic.Menu.Item>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
<Components.Generic.Menu.Dropdown sub className="bn-menu-dropdown">
|
||||
{convertOptions.map((option) => (
|
||||
<Components.Generic.Menu.Item
|
||||
key={option.label}
|
||||
className="bn-menu-item flex items-center justify-between gap-4"
|
||||
onClick={() => convertBlock(option)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.shortcut && <span className="text-[10px] text-gray-400">{option.shortcut}</span>}
|
||||
</Components.Generic.Menu.Item>
|
||||
))}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
</Components.Generic.Menu.Root>
|
||||
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={duplicateBlock}>
|
||||
拷贝副本
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={copyBlockLink}>
|
||||
复制链接
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={moveOrEmbedBlock}>
|
||||
移动/嵌入到...
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
onClick={() => window.alert("块历史功能开发中,敬请期待")}
|
||||
>
|
||||
块历史...
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
onClick={() => window.alert("评论功能暂未开放")}
|
||||
>
|
||||
评论
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={handleDeleteBlock}>
|
||||
删除
|
||||
</Components.Generic.Menu.Item>
|
||||
|
||||
<BlockColorsItem block={block}>颜色</BlockColorsItem>
|
||||
<TableRowHeaderItem block={block as TableMenuBlock}>表头(行)</TableRowHeaderItem>
|
||||
<TableColumnHeaderItem block={block as TableMenuBlock}>表头(列)</TableColumnHeaderItem>
|
||||
|
||||
{block.type === "advancedTodo" && (
|
||||
<>
|
||||
<Components.Generic.Menu.Divider className="bn-menu-divider" />
|
||||
{[
|
||||
{ label: "设为未开始", status: "todo" as const },
|
||||
{ label: "设为进行中", status: "doing" as const },
|
||||
{ label: "设为已完成", status: "done" as const },
|
||||
{ label: "设为取消", status: "cancelled" as const },
|
||||
].map((item) => (
|
||||
<Components.Generic.Menu.Item
|
||||
key={item.status}
|
||||
className="bn-menu-item"
|
||||
onClick={() => setAdvancedTodoStatus(item.status)}
|
||||
>
|
||||
{item.label}
|
||||
</Components.Generic.Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{block.type === "progressMeter" && (
|
||||
<>
|
||||
<Components.Generic.Menu.Divider className="bn-menu-divider" />
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={toggleProgressMode}>
|
||||
{block.props.auto ? "切换为手动进度" : "切换为自动进度"}
|
||||
</Components.Generic.Menu.Item>
|
||||
{!block.props.auto && (
|
||||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={setManualProgress}>
|
||||
手动设置百分比
|
||||
</Components.Generic.Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Components.Generic.Menu.Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||||
currentDocumentId: string;
|
||||
};
|
||||
|
||||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||
<SideMenu
|
||||
{...props}
|
||||
dragHandleMenu={(dragProps) => (
|
||||
<CustomDragHandleMenu
|
||||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
||||
currentDocumentId={props.currentDocumentId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { JSX } from "react";
|
||||
import {
|
||||
SuggestionMenuController,
|
||||
getDefaultReactSlashMenuItems,
|
||||
type DefaultReactSuggestionItem,
|
||||
} from "@blocknote/react";
|
||||
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileImage,
|
||||
FilePlus2,
|
||||
FileVideo,
|
||||
ListTree,
|
||||
Music,
|
||||
Paperclip,
|
||||
PilcrowSquare,
|
||||
Play,
|
||||
Sparkles,
|
||||
SquareCheckBig,
|
||||
} from "lucide-react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind, MediaSelection } from "@/types/media";
|
||||
|
||||
type Props = {
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
currentDocumentId: string;
|
||||
};
|
||||
|
||||
const matchKeywords = (query: string, aliases: string[]) => {
|
||||
const lower = query.trim().toLowerCase();
|
||||
if (!lower) return true;
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const GROUP_TRANSLATIONS: Record<string, string> = {
|
||||
"Headings": "标题",
|
||||
"Subheadings": "副标题",
|
||||
"Basic blocks": "基础块",
|
||||
"Advanced": "高级",
|
||||
"Media": "媒体",
|
||||
"Others": "其他",
|
||||
};
|
||||
|
||||
const DEFAULT_ITEM_TRANSLATIONS: Record<
|
||||
string,
|
||||
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
|
||||
> = {
|
||||
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
|
||||
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
|
||||
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
|
||||
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
|
||||
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
|
||||
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
|
||||
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
|
||||
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
|
||||
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
|
||||
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
|
||||
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
|
||||
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
|
||||
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
|
||||
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
|
||||
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
|
||||
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
|
||||
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
|
||||
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
|
||||
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
|
||||
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
|
||||
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
|
||||
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
|
||||
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
|
||||
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
|
||||
};
|
||||
|
||||
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
|
||||
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
|
||||
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
|
||||
audio: <Music className="h-4 w-4 text-[#10b981]" />,
|
||||
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
|
||||
};
|
||||
|
||||
const HEADING_PRESETS = [
|
||||
{
|
||||
level: 1,
|
||||
title: "主标题",
|
||||
subtext: "适合页面名称/顶层章节",
|
||||
aliases: ["biaoti1", "h1", "level1"],
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
title: "大标题",
|
||||
subtext: "用于章节逻辑层",
|
||||
aliases: ["biaoti2", "h2", "level2"],
|
||||
},
|
||||
{
|
||||
level: 3,
|
||||
title: "中标题",
|
||||
subtext: "用于小节和段落",
|
||||
aliases: ["biaoti3", "h3", "level3"],
|
||||
},
|
||||
{
|
||||
level: 4,
|
||||
title: "小标题",
|
||||
subtext: "更细的结构说明",
|
||||
aliases: ["biaoti4", "h4", "level4"],
|
||||
},
|
||||
{
|
||||
level: 5,
|
||||
title: "极小标题",
|
||||
subtext: "适合脚注/补充说明",
|
||||
aliases: ["biaoti5", "h5", "level5"],
|
||||
},
|
||||
];
|
||||
|
||||
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
|
||||
const maybeKey = (item as { key?: string }).key ?? "";
|
||||
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
|
||||
return true;
|
||||
}
|
||||
const title = item.title ?? "";
|
||||
return title.includes("标题");
|
||||
};
|
||||
|
||||
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
|
||||
const router = useRouter();
|
||||
const { openPicker } = useImagePicker();
|
||||
|
||||
const getItems = useCallback(
|
||||
async (query: string) => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
|
||||
|
||||
const createPageItem: DefaultReactSuggestionItem = {
|
||||
title: "嵌入页面",
|
||||
group: "嵌入",
|
||||
aliases: ["page", "ym", "子页面", "嵌入页面块"],
|
||||
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: async () => {
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parentId: currentDocumentId,
|
||||
title: cursor?.block?.content?.[0]?.text ?? "未命名页面",
|
||||
blocks: cursor ? [cursor.block] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
const { pageId, title } = await response.json();
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
router.refresh();
|
||||
},
|
||||
};
|
||||
|
||||
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
|
||||
title: preset.title,
|
||||
group: "标题",
|
||||
subtext: preset.subtext,
|
||||
aliases: preset.aliases,
|
||||
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: preset.level },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const foldHeading: DefaultReactSuggestionItem = {
|
||||
title: "折叠标题",
|
||||
group: "标题",
|
||||
aliases: ["toggle", "zd", "fold"],
|
||||
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "heading",
|
||||
props: { level: 2, isToggleable: true },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const advancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "高级待办",
|
||||
group: "待办",
|
||||
subtext: "四态状态 · Alt 直接取消",
|
||||
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
|
||||
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const progressMeter: DefaultReactSuggestionItem = {
|
||||
title: "进度条",
|
||||
group: "进度",
|
||||
subtext: "自动读取下方待办完成度",
|
||||
aliases: ["jdt", "progress", "jindu"],
|
||||
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "progressMeter",
|
||||
props: { percent: 0, auto: true },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const foldAdvancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "折叠高级待办",
|
||||
group: "待办",
|
||||
aliases: ["zdgjdb", "foldtodo"],
|
||||
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
|
||||
onItemClick: () => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const customItems = [
|
||||
...headingItems,
|
||||
foldHeading,
|
||||
createPageItem,
|
||||
advancedTodo,
|
||||
foldAdvancedTodo,
|
||||
progressMeter,
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
const insertMediaSelection = (selection: MediaSelection) => {
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "media",
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? "image",
|
||||
fileName: selection.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
},
|
||||
},
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
};
|
||||
|
||||
const handleMediaPick = (mediaType: MediaKind) => {
|
||||
openPicker({
|
||||
mediaType,
|
||||
onSelect: (selection) => {
|
||||
insertMediaSelection({
|
||||
...selection,
|
||||
assetType: selection.assetType ?? mediaType,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const localizedDefaults = defaultItems.map((item) => {
|
||||
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
|
||||
const next: DefaultReactSuggestionItem = { ...item };
|
||||
if (translation?.title) next.title = translation.title;
|
||||
if (translation?.subtext) next.subtext = translation.subtext;
|
||||
if (translation?.aliases) next.aliases = translation.aliases;
|
||||
if (translation?.group) {
|
||||
next.group = translation.group;
|
||||
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
|
||||
next.group = GROUP_TRANSLATIONS[item.group];
|
||||
}
|
||||
|
||||
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
|
||||
const mediaType = item.title.toLowerCase() as MediaKind;
|
||||
next.icon = MEDIA_ICONS[mediaType];
|
||||
next.group = translation?.group ?? "媒体";
|
||||
next.subtext = translation?.subtext ?? next.subtext;
|
||||
next.aliases = translation?.aliases ?? next.aliases;
|
||||
next.onItemClick = () => handleMediaPick(mediaType);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const sanitizedDefaults = localizedDefaults.filter(
|
||||
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
|
||||
);
|
||||
const merged = [...customItems, ...sanitizedDefaults];
|
||||
return filterSuggestionItems(merged, query);
|
||||
},
|
||||
[currentDocumentId, defaultItems, editor, openPicker, router],
|
||||
);
|
||||
|
||||
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBacklinks } from "@/hooks/use-backlinks";
|
||||
import type { BacklinkRecord } from "@/types/references";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageBacklinksPanelProps {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatRelative = (value: string) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const EmptyState = () => (
|
||||
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-6 text-center text-sm text-gray-500">
|
||||
暂无反向引用。试试输入 <code className="rounded bg-gray-100 px-1">[[</code> 或 <code className="rounded bg-gray-100 px-1">#</code>{" "}
|
||||
来引用其他页面。
|
||||
</div>
|
||||
);
|
||||
|
||||
const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
|
||||
<div className="rounded-xl border border-[#f1f5f9] bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<span>{record.alias || record.sourceTitle || "无标题"}</span>
|
||||
<span className="text-xs text-gray-400">{record.displayMode === "embed" ? "嵌入块" : "行内引用"}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
来自页面:{record.sourceTitle || "无标题"} · 更新:{formatRelative(record.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
|
||||
workspaceId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
if (!isLoading && !error && records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-[#eef2ff] bg-[#fdfdff] p-5 shadow-sm", className)}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-[#1f2933]">反向引用</p>
|
||||
<p className="text-xs text-gray-500">展示指向当前页面的所有页面或块引用</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? "刷新中..." : "刷新"}
|
||||
</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 />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{records.map((record) => (
|
||||
<BacklinkItem key={record.id} record={record} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import { DocumentTaskPanel } from "@/components/document-task-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type TabId = "page" | "custom" | "global";
|
||||
|
||||
const TABS: Array<{ id: TabId; label: string }> = [
|
||||
{ id: "page", label: "页面选项" },
|
||||
{ id: "custom", label: "自定义页面" },
|
||||
{ id: "global", label: "全局选项" },
|
||||
];
|
||||
|
||||
const OPTION_META: Record<
|
||||
keyof PageOptionsState,
|
||||
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
|
||||
> = {
|
||||
wideLayout: {
|
||||
label: "自适应宽度",
|
||||
description: "让编辑区域根据屏幕自动铺满",
|
||||
icon: Maximize2,
|
||||
},
|
||||
smallText: {
|
||||
label: "小字体",
|
||||
description: "使用更紧凑的字号排版",
|
||||
icon: Type,
|
||||
},
|
||||
showHeadingNumbers: {
|
||||
label: "标题编号",
|
||||
description: "自动为标题添加编号",
|
||||
icon: ListOrdered,
|
||||
},
|
||||
showToc: {
|
||||
label: "显示目录",
|
||||
description: "在右侧展示目录导航",
|
||||
icon: ListTree,
|
||||
},
|
||||
showStructure: {
|
||||
label: "块结构线框",
|
||||
description: "显示块级元素的结构边界",
|
||||
icon: Focus,
|
||||
},
|
||||
protectEditing: {
|
||||
label: "编辑保护",
|
||||
description: "保护内容避免误触修改",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
showWordCount: {
|
||||
label: "字数提示",
|
||||
description: "实时展示字数和块统计",
|
||||
icon: BookOpenCheck,
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
|
||||
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
|
||||
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
|
||||
|
||||
interface PageOptionsSidebarProps {
|
||||
documentId: string;
|
||||
options: PageOptionsState;
|
||||
stats?: DocumentStats;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
onExport: () => void;
|
||||
onOpenHistory: () => void;
|
||||
}
|
||||
|
||||
export function PageOptionsSidebar({
|
||||
documentId,
|
||||
options,
|
||||
stats,
|
||||
onToggle,
|
||||
onExport,
|
||||
onOpenHistory,
|
||||
}: PageOptionsSidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("page");
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
|
||||
<div className="flex border-b border-[#f5f5f5]">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 border-b-2 px-4 py-3 text-sm font-medium text-gray-500",
|
||||
activeTab === tab.id ? "border-[#2563eb] text-[#2563eb]" : "border-transparent hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === "page" && (
|
||||
<div className="space-y-4">
|
||||
{options.showWordCount && stats && (
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">页面数据</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-gray-500">
|
||||
<StatsCell label="字数" value={stats.wordCount} />
|
||||
<StatsCell label="字符" value={stats.characterCount} />
|
||||
<StatsCell label="块数" value={stats.blockCount} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onExport}>
|
||||
导出
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
|
||||
历史
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">导出最新快照或打开历史版本面板。</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}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionToggleGroup({
|
||||
title,
|
||||
optionKeys,
|
||||
options,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
optionKeys: (keyof PageOptionsState)[];
|
||||
options: PageOptionsState;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-400">{title}</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{optionKeys.map((key) => (
|
||||
<OptionToggle key={key} optionKey={key} options={options} onToggle={onToggle} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionToggle({
|
||||
optionKey,
|
||||
options,
|
||||
onToggle,
|
||||
}: {
|
||||
optionKey: keyof PageOptionsState;
|
||||
options: PageOptionsState;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
}) {
|
||||
const meta = OPTION_META[optionKey];
|
||||
const Icon = meta.icon;
|
||||
const active = options[optionKey];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl border border-transparent bg-[#f9fafc] px-3 py-2 text-left shadow-sm transition hover:border-[#dbe7ff]"
|
||||
onClick={() => onToggle(optionKey)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-2",
|
||||
active ? "border-[#cddfff] bg-[#eef4ff]" : "border-transparent bg-white",
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", active ? "text-[#2563eb]" : "text-gray-500")} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{meta.label}</div>
|
||||
<div className="text-xs text-gray-400">{meta.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("text-xs font-semibold", active ? "text-[#2563eb]" : "text-gray-400")}>
|
||||
{active ? "已开启" : "已关闭"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsCell({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white py-3 text-center shadow-sm">
|
||||
<div className="text-xs text-gray-400">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
createHeadingBlockSpec,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
defaultStyleSpecs,
|
||||
} from "@blocknote/core";
|
||||
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
|
||||
import { advancedTodoBlock } from "./blocks/AdvancedTodoBlock";
|
||||
import { progressBlock } from "./blocks/ProgressBlock";
|
||||
import { mediaBlock } from "./blocks/MediaBlock";
|
||||
|
||||
const headingSpec =
|
||||
typeof window === "undefined"
|
||||
? defaultBlockSpecs.heading
|
||||
: createHeadingBlockSpec({
|
||||
levels: [1, 2, 3, 4, 5],
|
||||
allowToggleHeadings: true,
|
||||
});
|
||||
|
||||
export const customBlockSchema = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
heading: headingSpec,
|
||||
pageReference: pageReferenceBlock,
|
||||
advancedTodo: advancedTodoBlock,
|
||||
progressMeter: progressBlock,
|
||||
media: mediaBlock,
|
||||
},
|
||||
inlineContentSpecs: defaultInlineContentSpecs,
|
||||
styleSpecs: defaultStyleSpecs,
|
||||
});
|
||||
|
||||
export type CustomBlockSchema = typeof customBlockSchema.blockSchema;
|
||||
Reference in New Issue
Block a user