0.3.6 修复shitf选择问题

This commit is contained in:
liaibo
2026-01-24 13:14:24 +08:00
parent 3c3f407f4b
commit a4183d86d8
4 changed files with 174 additions and 12 deletions
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
@@ -358,6 +358,7 @@ export default function OnlyOfficePage() {
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
const [permissionResolved, setPermissionResolved] = useState(false);
const [assetSignedUrl, setAssetSignedUrl] = useState<string>("");
const [assetStorageId, setAssetStorageId] = useState<string>("");
const [forceSaveState, setForceSaveState] = useState<{
@@ -413,6 +414,7 @@ export default function OnlyOfficePage() {
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
const docEditorRef = useRef<any>(null);
useEffect(() => {
// 说明:为了让 document.key 在同一附件的多次打开之间保持稳定(提升缓存命中与加载速度),
@@ -472,12 +474,17 @@ export default function OnlyOfficePage() {
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
setPermissionResolved(true);
return;
}
if (!authedUserId) {
setPermissionResolved(false);
return;
}
if (!authedUserId) return;
let canceled = false;
void (async () => {
setPermissionResolved(false);
try {
const perm = await convex.query(api.documents.getPermissionForUser, {
userId: authedUserId,
@@ -488,6 +495,7 @@ export default function OnlyOfficePage() {
const disableCopy = Boolean((perm as any)?.disableCopy);
setResolvedDisableDownload(disableDownload);
setResolvedDisableCopy(disableCopy);
setPermissionResolved(true);
if (mode === "view") {
setResolvedMode("view");
@@ -504,6 +512,7 @@ export default function OnlyOfficePage() {
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
setPermissionResolved(true);
}
})();
@@ -731,7 +740,12 @@ export default function OnlyOfficePage() {
fileName,
fileType,
mode,
resolvedMode,
resolvedDisableDownload,
resolvedDisableCopy,
permissionResolved,
assetId,
documentId,
docKey,
};
} catch {
@@ -747,11 +761,34 @@ export default function OnlyOfficePage() {
fileName,
fileType,
resolvedMode,
resolvedDisableCopy,
resolvedDisableDownload,
permissionResolved,
documentId,
proxyOrigin,
resolvedFileUrl,
]);
useEffect(() => {
let canceled = false;
const destroyEditor = () => {
try {
const inst = docEditorRef.current;
docEditorRef.current = null;
if (inst && typeof inst.destroyEditor === "function") {
inst.destroyEditor();
}
} catch {
// ignore
}
try {
const el = document.getElementById("onlyoffice-frame");
if (el) el.innerHTML = "";
} catch {
// ignore
}
};
// 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让
// /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。
// 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。
@@ -789,6 +826,7 @@ export default function OnlyOfficePage() {
}
if (!resolvedMode) return;
if (documentId && !permissionResolved) return;
if (resolvedMode !== "view" && assetId && !authedUserId) return;
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
@@ -909,6 +947,7 @@ export default function OnlyOfficePage() {
documentToken: string | null;
editorConfigToken: string | null;
};
if (canceled) return;
// 兼容不同 ONLYOFFICE 配置:有些版本/配置会校验 document.token/editorConfig.token。
if (token) config.token = token;
@@ -922,7 +961,8 @@ export default function OnlyOfficePage() {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
if (canceled) return;
docEditorRef.current = new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
})
.catch((err: Error) => {
// 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。
@@ -938,6 +978,11 @@ export default function OnlyOfficePage() {
}
setError(err.message);
});
return () => {
canceled = true;
destroyEditor();
};
}, [
assetId,
authedUserId,
@@ -952,6 +997,11 @@ export default function OnlyOfficePage() {
mode,
proxyOrigin,
resolvedFileUrl,
documentId,
permissionResolved,
resolvedDisableCopy,
resolvedDisableDownload,
resolvedMode,
targetDocType,
runtimeConfig.onlyofficeBaseUrlDesktop,
]);
@@ -46,7 +46,7 @@ import { useCurrentDocumentStore } from "@/store/current-document";
import { FileTree } from "@/components/sidebar/file-tree";
import { buildVisibleRows } from "@/lib/file-tree/rows";
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
import { reduceFileTreeSelection } from "@/lib/file-tree/selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
import { isRealFileAsset } from "@/lib/file-tree/asset";
@@ -260,10 +260,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
setTableAssets(sidebarData.tableAssets ?? []);
}, [sidebarData.tableAssets]);
useEffect(() => {
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
}, [mediaAssets, mindmapAssets, tableAssets, sidebarData.documents]);
useEffect(() => {
setOpen(false);
}, [activeId, setOpen]);
@@ -566,6 +562,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
useEffect(() => {
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds));
}, [fileTreeVisibleRowIds]);
const docParentById = useMemo(
() =>
buildParentById(
@@ -1703,6 +1703,61 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[activeId, refreshTree, router],
);
const handleDeleteFromContextMenuNode = useCallback(
async (node: DocumentNode) => {
if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection();
return;
}
const ok = window.confirm("确认删除该页面到垃圾桶吗?");
if (!ok) return;
try {
await handleDelete(node.id);
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDelete, handleDeleteFileTreeSelection, viewMode],
);
const handleDeleteFromAssetContextMenu = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
if (uniqueAssetIds.length === 0) return;
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);
const parts: string[] = [];
if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`);
if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`);
const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`);
if (!ok) return;
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets],
);
const handleConvertToChild = useCallback(
async (documentId: string) => {
const flat = flattenedPrivate;
@@ -2373,7 +2428,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
onCreateChild={() => void handleCreate(contextMenu.node.id)}
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
onDelete={() => void handleDeleteFileTreeSelection()}
onDelete={() => void handleDeleteFromContextMenuNode(contextMenu.node)}
/>
)}
{shareTarget && (
@@ -2449,7 +2504,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={() => void handleDeleteFileTreeSelection()}
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
onDownload={handleDownloadAsset}
/>
)}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { reduceFileTreeSelection } from "./selection";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "./selection";
describe("reduceFileTreeSelection", () => {
const visible = ["a", "b", "c", "d"];
@@ -56,5 +56,14 @@ describe("reduceFileTreeSelection", () => {
expect(next.anchorRowId).toBeNull();
expect(next.focusedRowId).toBeNull();
});
});
it("可见行变化时保留仍可见的选择", () => {
const next = normalizeFileTreeSelectionForVisibleRows(
{ selectedRowIds: new Set(["b", "x"]), anchorRowId: "b", focusedRowId: "x" },
["a", "b", "c"],
);
expect(Array.from(next.selectedRowIds)).toEqual(["b"]);
expect(next.anchorRowId).toBe("b");
expect(next.focusedRowId).toBe("b");
});
});
@@ -82,3 +82,51 @@ export function reduceFileTreeSelection(
}
}
export function normalizeFileTreeSelectionForVisibleRows(
prev: FileTreeSelectionState,
visibleRowIds: string[],
): FileTreeSelectionState {
const visibleSet = new Set(visibleRowIds);
let selectedRowIds = prev.selectedRowIds;
let removedAny = false;
const kept: string[] = [];
prev.selectedRowIds.forEach((id) => {
if (visibleSet.has(id)) {
kept.push(id);
return;
}
removedAny = true;
});
if (removedAny) {
selectedRowIds = new Set(kept);
}
let anchorRowId = prev.anchorRowId;
if (anchorRowId && !visibleSet.has(anchorRowId)) {
anchorRowId = null;
}
let focusedRowId = prev.focusedRowId;
if (focusedRowId && !visibleSet.has(focusedRowId)) {
focusedRowId = null;
}
if (!focusedRowId && selectedRowIds.size > 0) {
focusedRowId = visibleRowIds.find((id) => selectedRowIds.has(id)) ?? null;
}
if (!anchorRowId && focusedRowId) {
anchorRowId = focusedRowId;
}
if (
selectedRowIds === prev.selectedRowIds &&
anchorRowId === prev.anchorRowId &&
focusedRowId === prev.focusedRowId
) {
return prev;
}
return { selectedRowIds, anchorRowId, focusedRowId };
}