Fix tiptap selection sync and toolbar event loop

This commit is contained in:
lix-2026
2026-04-19 21:03:25 +08:00
parent 111a87d4fd
commit 394e2a155c
87 changed files with 17415 additions and 527 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
describe("page subtree response helpers", () => {
it("保留服务端返回的 pageSubtree", () => {
const serverPageSubtree = {
projectionId: "server_projection",
projection: "page_tree",
rootNodeId: "doc_1",
rootNode: {
id: "doc_1",
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title: "服务端标题",
textSnippet: null,
blockType: null,
headingLevel: null,
numbering: null,
childCount: 0,
order: 0,
path: ["doc_1"],
},
},
subtree: {
rootNodeId: "doc_1",
nodes: [],
},
outline: [],
evidence: [],
stats: {
blockCount: 0,
headingCount: 0,
evidenceCount: 0,
maxDepth: 0,
},
} satisfies PageSubtreeProjection;
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
payload: {
content: null,
revision: 1,
conflictDetectionKey: "doc_1:1",
pageSubtree: serverPageSubtree,
},
});
expect(normalized.pageSubtree).toBe(serverPageSubtree);
});
it("在服务端缺失 pageSubtree 时保留空值", () => {
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
title: "测试页面",
payload: {
content: [
{
id: "heading_1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "章节一" }],
},
],
revision: 3,
conflictDetectionKey: "doc_1:3",
},
});
expect(normalized.revision).toBe(3);
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
expect(normalized.pageSubtree).toBeNull();
});
});
@@ -0,0 +1,48 @@
import {
type PageSubtreeProjection,
} from "@/lib/documents/page-subtree";
export type DocumentContentResponseLike = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
conflictDetectionKey?: string | null;
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
};
export type NormalizedDocumentContentResponse = {
content: unknown;
revision: number;
conflictDetectionKey: string;
pageSubtree: PageSubtreeProjection | null;
};
export function normalizeDocumentContentResponse(input: {
documentId: string;
title?: string | null;
payload?: DocumentContentResponseLike | null;
}): NormalizedDocumentContentResponse {
const payload = input.payload ?? null;
const content = payload?.content ?? null;
const revision =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0;
const conflictDetectionKey = (
typeof payload?.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: typeof payload?.conflict_detection_key === "string" && payload.conflict_detection_key.trim()
? payload.conflict_detection_key
: `${input.documentId}:0`
);
const pageSubtree = payload?.pageSubtree ?? payload?.page_subtree ?? null;
return {
content,
revision,
conflictDetectionKey,
pageSubtree,
};
}
+52 -10
View File
@@ -73,6 +73,36 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
return undefined;
};
function getServerNodeBuiltin<T>(moduleName: string): T | null {
if (typeof window !== "undefined") {
return null;
}
const processWithBuiltin = process as NodeJS.Process & {
getBuiltinModule?: (id: string) => unknown;
};
if (typeof processWithBuiltin.getBuiltinModule === "function") {
const builtIn = processWithBuiltin.getBuiltinModule(moduleName);
if (builtIn) {
return builtIn as T;
}
}
try {
// 说明:某些 Node/打包环境仍保留 CommonJS require,这里只作为兜底。
const runtimeRequire = new Function(
'return typeof require === "function" ? require : null',
)() as NodeJS.Require | null;
if (runtimeRequire) {
return runtimeRequire(moduleName) as T;
}
} catch {
return null;
}
return null;
}
const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
@@ -111,12 +141,14 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
// 说明:这里不能直接写 `require("fs")` / `require("path")`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页
// 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json
const runtimeRequire = new Function("return require")() as NodeJS.Require;
const fs = runtimeRequire("node:fs") as typeof import("fs");
const path = runtimeRequire("node:path") as typeof import("path");
// 说明:这里不能直接静态引入 `node:fs` / `node:path`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
// Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require
const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
const path = getServerNodeBuiltin<typeof import("path")>("node:path");
if (!fs || !path) {
return {};
}
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
@@ -224,18 +256,28 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
}
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const publicRuntime = readFromPublicJson();
const envRuntime = readFromEnv();
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
const merged: MnoteRuntimeConfig = isDesktop
? {
...readFromEnv(),
...readFromPublicJson(),
...envRuntime,
...publicRuntime,
isDesktop,
}
: {
...readFromPublicJson(),
...readFromEnv(),
...publicRuntime,
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
...(publicRuntime.mnoteWebBaseUrl !== undefined
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
: {}),
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
: {}),
isDesktop,
};
return normalizeRuntimeConfig(merged);