0.4.0 convex及界面修改
This commit is contained in:
@@ -19,6 +19,7 @@ import type { MediaKind } from "@/types/media";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -81,6 +82,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileUrl = block.props.fileUrl as string;
|
||||
const browserFileUrl = useMemo(() => toBrowserAccessibleUrl(fileUrl) ?? fileUrl, [fileUrl]);
|
||||
const rawThumbUrl = (block.props.thumbnailUrl as string | undefined) || "";
|
||||
const browserThumbUrl = useMemo(
|
||||
() => (rawThumbUrl ? toBrowserAccessibleUrl(rawThumbUrl) ?? rawThumbUrl : ""),
|
||||
[rawThumbUrl],
|
||||
);
|
||||
const rawAssetType = (block.props.assetType as string) || "image";
|
||||
const assetType: MediaKind =
|
||||
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
|
||||
@@ -279,7 +286,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const openWithOnlyOffice = async () => {
|
||||
if (!fileUrl) return;
|
||||
if (!officeBase) {
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
return;
|
||||
}
|
||||
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
|
||||
@@ -400,10 +407,10 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
<video
|
||||
controls
|
||||
className="max-h-[420px] w-full rounded-2xl bg-black"
|
||||
poster={block.props.thumbnailUrl || undefined}
|
||||
poster={browserThumbUrl || undefined}
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
>
|
||||
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
|
||||
<source src={browserFileUrl} type={block.props.mimeType || "video/mp4"} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -411,7 +418,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
||||
<source src={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
||||
</audio>
|
||||
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
|
||||
</div>
|
||||
@@ -533,7 +540,13 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
return (
|
||||
<img
|
||||
src={browserThumbUrl || browserFileUrl}
|
||||
alt={block.props.caption || typeLabel}
|
||||
style={inlineStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const figure = (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
|
||||
type AgentAssetItem = {
|
||||
kind: "media" | "local-mindmap" | "test-pdf";
|
||||
@@ -158,11 +159,12 @@ export function MindmapAiAgentPanel({
|
||||
activeNodes: unknown[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
@@ -185,6 +187,12 @@ export function MindmapAiAgentPanel({
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const syncTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
|
||||
@@ -34,6 +34,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import iconConfig from "./mindmapIconConfig";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
||||
const loadIconModules = async () => {
|
||||
@@ -462,6 +465,11 @@ const MindmapBlockView = ({
|
||||
const mindmapReadyRef = useRef(false);
|
||||
const mindmapRef = useRef<MindMapInstance | null>(null);
|
||||
const hasLocalEditsRef = useRef(false);
|
||||
const lastLocalEditAtRef = useRef(0);
|
||||
const markLocalEdited = useCallback(() => {
|
||||
hasLocalEditsRef.current = true;
|
||||
lastLocalEditAtRef.current = Date.now();
|
||||
}, []);
|
||||
const applyingRemoteRef = useRef(false);
|
||||
const [canBack, setCanBack] = useState(false);
|
||||
const [canForward, setCanForward] = useState(false);
|
||||
@@ -513,7 +521,10 @@ const MindmapBlockView = ({
|
||||
if (!docId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
|
||||
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
|
||||
const res = await fetch(
|
||||
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
|
||||
);
|
||||
const json: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) return;
|
||||
if (cancelled) return;
|
||||
@@ -530,6 +541,7 @@ const MindmapBlockView = ({
|
||||
|
||||
useEffect(() => {
|
||||
hasLocalEditsRef.current = false;
|
||||
lastLocalEditAtRef.current = 0;
|
||||
applyingRemoteRef.current = false;
|
||||
}, [docId]);
|
||||
|
||||
@@ -537,6 +549,15 @@ const MindmapBlockView = ({
|
||||
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
|
||||
return `${STORAGE_PREFIX}${mindmapId}`;
|
||||
}, [docId, mindmapId]);
|
||||
|
||||
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
|
||||
const lastLocalSavedAtRef = useRef<string | null>(null);
|
||||
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
|
||||
|
||||
const remoteMindmap = useQuery(
|
||||
api.mindmaps.get,
|
||||
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
|
||||
);
|
||||
const initialDataRef = useRef<unknown>(null);
|
||||
if (initialDataRef.current === null) {
|
||||
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
|
||||
@@ -584,6 +605,83 @@ const MindmapBlockView = ({
|
||||
};
|
||||
}, [docId, mindmap, mindmapId]);
|
||||
|
||||
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
|
||||
useEffect(() => {
|
||||
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
|
||||
|
||||
const meta = (remoteMindmap as any).meta as Record<string, unknown> | undefined;
|
||||
const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null;
|
||||
const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null;
|
||||
|
||||
if (deletedAt) {
|
||||
if (!docId || deletingRef.current) return;
|
||||
deletingRef.current = true;
|
||||
try {
|
||||
window.localStorage.removeItem(autosaveKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 全屏页:退出到文档页
|
||||
if (effectiveFullscreen && typeof onExitFullscreen === "function") {
|
||||
window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。");
|
||||
onExitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// 嵌入编辑器:复用编辑器监听链路移除块
|
||||
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updatedAt) return;
|
||||
if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return;
|
||||
// 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入
|
||||
if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) {
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
return;
|
||||
}
|
||||
// 本地仍有未同步编辑时,不覆盖
|
||||
if (hasLocalEditsRef.current || deletingRef.current) return;
|
||||
|
||||
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
initialDataRef.current = incoming;
|
||||
try {
|
||||
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!effectiveFullscreen) {
|
||||
try {
|
||||
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (mindmap) {
|
||||
applyingRemoteRef.current = true;
|
||||
try {
|
||||
mindmap.setData(incoming);
|
||||
mindmap.command.clearHistory();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
applyingRemoteRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autosaveKey,
|
||||
block,
|
||||
docId,
|
||||
editor,
|
||||
effectiveFullscreen,
|
||||
mindmap,
|
||||
mindmapId,
|
||||
onExitFullscreen,
|
||||
remoteMindmap,
|
||||
]);
|
||||
|
||||
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
|
||||
useEffect(() => {
|
||||
const onPointerDownCapture = (e: Event) => {
|
||||
@@ -956,7 +1054,7 @@ const MindmapBlockView = ({
|
||||
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
|
||||
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
|
||||
// 切换全屏/刷新后不会丢失。
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -974,7 +1072,7 @@ const MindmapBlockView = ({
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -993,7 +1091,7 @@ const MindmapBlockView = ({
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
inst.execCommand?.("REMOVE_NODE");
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1029,7 +1127,7 @@ const MindmapBlockView = ({
|
||||
const copyData = renderer.beingCopyData ?? null;
|
||||
if (copyData) {
|
||||
inst.execCommand?.("PASTE_NODE", copyData);
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1039,7 +1137,7 @@ const MindmapBlockView = ({
|
||||
return;
|
||||
}
|
||||
renderer.paste?.();
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1053,7 +1151,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
|
||||
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
|
||||
@@ -1114,7 +1212,7 @@ const MindmapBlockView = ({
|
||||
}
|
||||
|
||||
// 兜底持久化:避免快速切换视图导致“看起来没保存”
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1127,7 +1225,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("paste", onPasteCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
|
||||
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
|
||||
@@ -1243,7 +1341,7 @@ const MindmapBlockView = ({
|
||||
// ignore
|
||||
}
|
||||
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1256,7 +1354,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
const persistData = useCallback(
|
||||
(data: unknown) => {
|
||||
@@ -1279,14 +1377,30 @@ const MindmapBlockView = ({
|
||||
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
||||
}
|
||||
if (docId) {
|
||||
// 同步到本地文件 + Supabase(弱依赖)
|
||||
// 同步到本地文件 + Convex(弱依赖)
|
||||
const requestStartedAt = Date.now();
|
||||
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data: safe }),
|
||||
})
|
||||
.then((resp) => {
|
||||
.then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
try {
|
||||
const payload = (await resp.json().catch(() => null)) as any;
|
||||
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
|
||||
if (updatedAt) {
|
||||
lastLocalSavedAtRef.current = updatedAt;
|
||||
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
|
||||
if (lastLocalEditAtRef.current <= requestStartedAt) {
|
||||
hasLocalEditsRef.current = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: mindmapId,
|
||||
@@ -1699,7 +1813,7 @@ const MindmapBlockView = ({
|
||||
!deletingRef.current &&
|
||||
shouldPersistAfterCommand(cmd)
|
||||
) {
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
@@ -1845,13 +1959,13 @@ const MindmapBlockView = ({
|
||||
});
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
instance.on?.("data_change", () => {
|
||||
if (!applyingRemoteRef.current) {
|
||||
hasLocalEditsRef.current = true;
|
||||
}
|
||||
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
|
||||
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
||||
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
||||
instance.on?.("data_change", () => {
|
||||
if (!applyingRemoteRef.current) {
|
||||
markLocalEdited();
|
||||
}
|
||||
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
|
||||
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
||||
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (snapshot) schedulePersist(snapshot);
|
||||
@@ -2688,7 +2802,7 @@ const MindmapBlockView = ({
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
|
||||
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
|
||||
if (!confirmed) return;
|
||||
deletingRef.current = true;
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
|
||||
|
||||
@@ -11,7 +11,15 @@ const normalizeTitle = (value?: string | null) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||
const PageReferenceContent = ({
|
||||
pageId,
|
||||
title,
|
||||
asChildPage,
|
||||
}: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
asChildPage: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
|
||||
// 页面引用的标题会由编辑器同步更新 block.props.title。
|
||||
@@ -25,6 +33,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
|
||||
return (
|
||||
<div
|
||||
data-child-page={asChildPage ? "true" : "false"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={navigate}
|
||||
@@ -52,10 +61,17 @@ export const pageReferenceBlock = createReactBlockSpec(
|
||||
propSchema: {
|
||||
pageId: { default: "" },
|
||||
title: { default: "未命名页面" },
|
||||
asChildPage: { default: false },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
|
||||
render: ({ block }) => (
|
||||
<PageReferenceContent
|
||||
pageId={block.props.pageId}
|
||||
title={block.props.title}
|
||||
asChildPage={Boolean((block.props as any).asChildPage)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
)();
|
||||
|
||||
Reference in New Issue
Block a user