chore: 回收旧 BlockNote 编辑器组件
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"use strict";
|
||||
|
||||
// 说明:
|
||||
// - 这是 Sidebar / PageTree Rust Web tree shell 的真实网页 smoke 脚本。
|
||||
// - 目标覆盖:主页面已挂载 iframe tree shell、展开/折叠、在 shell 内创建子页面、重命名、移动、导航。
|
||||
// - 脚本会创建临时页面并在结束后清理,避免污染现有数据。
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "";
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const UI_TIMEOUT_MS = 30_000;
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function requireMnoteWebSmokeBaseUrl() {
|
||||
assert(
|
||||
Boolean(MNOTE_WEB_BASE_URL),
|
||||
"当前 smoke 仅用于 legacy mnote-web tree shell,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
|
||||
);
|
||||
return MNOTE_WEB_BASE_URL;
|
||||
}
|
||||
|
||||
async function requestJson(requestContext, path, init = {}) {
|
||||
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers:
|
||||
init.data !== undefined
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
...(init.headers || {}),
|
||||
}
|
||||
: {
|
||||
...(init.headers || {}),
|
||||
},
|
||||
timeout: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers()["content-type"] || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
|
||||
throw new Error(`${path} 返回了非 JSON 内容:${snippet}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createTempDocument(requestContext, parentId = null) {
|
||||
const payload = await requestJson(requestContext, "/api/documents/create", {
|
||||
method: "POST",
|
||||
data: { parentId },
|
||||
});
|
||||
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
|
||||
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
|
||||
return {
|
||||
documentId: payload.id,
|
||||
workspaceId: payload.workspace_id,
|
||||
};
|
||||
}
|
||||
|
||||
async function renameDocument(requestContext, workspaceId, documentId, title) {
|
||||
await requestJson(requestContext, "/api/documents/title", {
|
||||
method: "POST",
|
||||
data: {
|
||||
workspaceId,
|
||||
documentId,
|
||||
title,
|
||||
commandName: "page.head.updateTitle",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeDocument(requestContext, documentId) {
|
||||
await requestJson(requestContext, "/api/documents/purge", {
|
||||
method: "POST",
|
||||
data: { documentId },
|
||||
});
|
||||
}
|
||||
|
||||
async function getViewerIdentity(requestContext) {
|
||||
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
|
||||
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureAuthenticated(page, requestContext) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
if (page.url().includes("/auth")) {
|
||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
return await getViewerIdentity(requestContext);
|
||||
}
|
||||
|
||||
async function runTreeShellRegression(page, requestContext, viewer, target) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
const uniqueSuffix = Date.now().toString();
|
||||
const rootTitle = `task091-root-${uniqueSuffix}`;
|
||||
const childTitle = `task091-child-${uniqueSuffix}`;
|
||||
const createdTitle = `task091-created-${uniqueSuffix}`;
|
||||
const renamedTitle = `task091-renamed-${uniqueSuffix}`;
|
||||
|
||||
await renameDocument(requestContext, target.workspaceId, target.parentId, rootTitle);
|
||||
await renameDocument(requestContext, target.workspaceId, target.childId, childTitle);
|
||||
|
||||
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const groupButton = page.getByRole("button", { name: "分组" });
|
||||
await groupButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await groupButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const sidebarPanel = page.getByText("页面树");
|
||||
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const iframe = page.locator('iframe[title="mnote-web tree shell"]');
|
||||
await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const iframeSrc = await iframe.getAttribute("src");
|
||||
assert(iframeSrc && iframeSrc.includes(`${runtimeBaseUrl}/tree`), `Sidebar 未挂载 mnote-web tree shell:${iframeSrc}`);
|
||||
assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId:${iframeSrc}`);
|
||||
|
||||
const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]');
|
||||
const waitForTreeReady = async () => {
|
||||
const frame = getTreeFrame();
|
||||
await frame.locator('[data-testid="tree-create-root"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
return frame;
|
||||
};
|
||||
const getFirstChildId = async (parentId) => {
|
||||
const frame = await waitForTreeReady();
|
||||
return await frame
|
||||
.locator(`.tree-node[data-node-id="${parentId}"] > .tree-children > .tree-node`)
|
||||
.first()
|
||||
.getAttribute("data-node-id");
|
||||
};
|
||||
const waitForFirstChildId = async (parentId, expectedId) => {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const actualId = await getFirstChildId(parentId);
|
||||
if (actualId === expectedId) {
|
||||
return actualId;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
return await getFirstChildId(parentId);
|
||||
};
|
||||
let treeFrame = await waitForTreeReady();
|
||||
|
||||
const parentRow = treeFrame.locator(`.tree-row[data-node-id="${target.parentId}"]`);
|
||||
const childRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
|
||||
await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const toggleButton = parentRow.locator('[data-testid="tree-node-toggle"]');
|
||||
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await childRow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const createResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200 &&
|
||||
(response.request().postData() || "").includes('"action":"create"'),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await parentRow.locator('[data-testid="tree-action-create"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
const createResponse = await createResponsePromise;
|
||||
const createPayload = await createResponse.json();
|
||||
const createdDocumentId = createPayload?.result?.documentId;
|
||||
assert(typeof createdDocumentId === "string" && createdDocumentId, "tree shell 创建子页面失败:缺少 documentId");
|
||||
await page.waitForURL((url) => url.toString().includes(`/documents/${createdDocumentId}`), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
target.createdIds.push(createdDocumentId);
|
||||
|
||||
treeFrame = await waitForTreeReady();
|
||||
const createdRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
|
||||
await createdRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept(renamedTitle));
|
||||
const renameResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200 &&
|
||||
(response.request().postData() || "").includes('"action":"rename"'),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await createdRow.locator('[data-testid="tree-action-rename"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await renameResponsePromise;
|
||||
treeFrame = await waitForTreeReady();
|
||||
const renamedRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
|
||||
await renamedRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const childOrderBeforeMove = await getFirstChildId(target.parentId);
|
||||
assert(
|
||||
childOrderBeforeMove === target.childId,
|
||||
`移动前的首个子节点异常:期望 ${target.childId},实际 ${childOrderBeforeMove}`,
|
||||
);
|
||||
|
||||
const moveResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200 &&
|
||||
(response.request().postData() || "").includes('"action":"move"'),
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await renamedRow.locator('[data-testid="tree-action-move-up"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await moveResponsePromise;
|
||||
const firstChildAfterMove = await waitForFirstChildId(target.parentId, createdDocumentId);
|
||||
assert(
|
||||
firstChildAfterMove === createdDocumentId,
|
||||
`移动后排序未生效:期望首个子节点为 ${createdDocumentId},实际 ${firstChildAfterMove}`,
|
||||
);
|
||||
|
||||
treeFrame = await waitForTreeReady();
|
||||
const latestChildRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
|
||||
await latestChildRow.locator('[data-testid="tree-node-open"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.toString().includes(`/documents/${target.childId}`), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
documentUrl,
|
||||
rootTitle,
|
||||
childTitle,
|
||||
createdTitle,
|
||||
renamedTitle,
|
||||
createdDocumentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPickerRegression(page, target) {
|
||||
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
|
||||
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const inspectorToggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
|
||||
await inspectorToggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const currentLabel = (await inspectorToggle.getAttribute("aria-label")) || "";
|
||||
if (currentLabel.includes("显示")) {
|
||||
await inspectorToggle.click({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
const moveEmbedButton = page.getByRole("button", { name: "移动/嵌入到..." });
|
||||
await moveEmbedButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await moveEmbedButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const pickerIframe = dialog.locator('iframe[title="mnote-web tree shell"]');
|
||||
await pickerIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const iframeSrc = await pickerIframe.getAttribute("src");
|
||||
assert(iframeSrc && iframeSrc.includes("mode=picker"), `picker 未以 tree shell 轻量模式挂载:${iframeSrc}`);
|
||||
assert(iframeSrc.includes("allowRootPick=1"), `picker 未透传 allowRootPick:${iframeSrc}`);
|
||||
assert(
|
||||
iframeSrc.includes(`excludeIds=${encodeURIComponent(target.parentId)}`),
|
||||
`picker 未透传 excludeIds:${iframeSrc}`,
|
||||
);
|
||||
|
||||
const frame = dialog.frameLocator('iframe[title="mnote-web tree shell"]');
|
||||
const rootPick = frame.locator('[data-testid="tree-picker-root"]');
|
||||
await rootPick.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await rootPick.click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let caughtError = null;
|
||||
let result = null;
|
||||
const createdIds = [];
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
const parent = await createTempDocument(context.request, null);
|
||||
createdIds.push(parent.documentId);
|
||||
const child = await createTempDocument(context.request, parent.documentId);
|
||||
createdIds.push(child.documentId);
|
||||
|
||||
result = await runTreeShellRegression(page, context.request, viewer, {
|
||||
workspaceId: parent.workspaceId,
|
||||
parentId: parent.documentId,
|
||||
childId: child.documentId,
|
||||
createdIds,
|
||||
});
|
||||
await runPickerRegression(page, {
|
||||
workspaceId: parent.workspaceId,
|
||||
parentId: parent.documentId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
workspaceId: parent.workspaceId,
|
||||
viewerUserId: viewer.userId,
|
||||
parentId: parent.documentId,
|
||||
childId: child.documentId,
|
||||
...result,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
for (const documentId of [...createdIds].reverse()) {
|
||||
try {
|
||||
await purgeDocument(context.request, documentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
MNOTE_WEB_BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
ensureAuthenticated,
|
||||
openDocument,
|
||||
prepareTempTreeFixture,
|
||||
requireMnoteWebSmokeBaseUrl,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function runDefaultShellPath(page, fixture) {
|
||||
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
||||
|
||||
await page.getByRole("button", { name: "进入编辑" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert(
|
||||
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
|
||||
"默认文档页不应继续挂载 mnote-web 文档壳 iframe",
|
||||
);
|
||||
await page.getByRole("button", { name: "进入编辑" }).click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.getByLabel("页面标题").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(".wolai-editor").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
return {
|
||||
compatReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function runRuntimeDebugPath(page, fixture) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.goto(
|
||||
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("#block-editor-list").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("#editor-command-slash").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
return {
|
||||
debugUrl: page.url(),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let caughtError = null;
|
||||
let fixture = null;
|
||||
|
||||
try {
|
||||
const viewer = await ensureAuthenticated(page, context.request);
|
||||
fixture = await prepareTempTreeFixture(context.request);
|
||||
|
||||
const primary = await runDefaultShellPath(page, fixture);
|
||||
const debug = await runRuntimeDebugPath(page, fixture);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
viewerUserId: viewer.userId,
|
||||
workspaceId: fixture.workspaceId,
|
||||
parentId: fixture.parentId,
|
||||
childId: fixture.childId,
|
||||
debugRuntimeBaseUrl: MNOTE_WEB_BASE_URL || null,
|
||||
primary,
|
||||
debug,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture) {
|
||||
try {
|
||||
await cleanupDocuments(context.request, fixture.createdIds);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
} else {
|
||||
console.error(
|
||||
`清理临时页面失败:${
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.stack || cleanupError.message
|
||||
: String(cleanupError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
ensureAuthenticated,
|
||||
prepareTempTreeFixture,
|
||||
openDocument,
|
||||
requireMnoteWebSmokeBaseUrl,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function openRuntimeDebug(page, fixture) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.goto(
|
||||
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let fixture = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
fixture = await prepareTempTreeFixture(context.request);
|
||||
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
||||
await openRuntimeDebug(page, fixture);
|
||||
|
||||
const textarea = page.locator("textarea[data-block-input-id]").first();
|
||||
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await textarea.click({ timeout: UI_TIMEOUT_MS });
|
||||
await textarea.fill("你好 runtime input", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("#editor-save-status").getByText("saved").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const value = await textarea.inputValue();
|
||||
assert(value === "你好 runtime input", `输入值不符合预期:${value}`);
|
||||
|
||||
const selection = await page.locator("#editor-selection-id").textContent();
|
||||
assert(selection && selection.includes("_block_1"), `selection 未更新到真实输入块:${selection}`);
|
||||
|
||||
const eventLog = (await page.locator("#editor-event-log").textContent()) || "";
|
||||
assert(
|
||||
eventLog.includes("human_editor_input.beforeinput"),
|
||||
`未记录 beforeinput runtime 标记:${eventLog}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: fixture.workspaceId,
|
||||
documentId: fixture.parentId,
|
||||
selection,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture) {
|
||||
try {
|
||||
await cleanupDocuments(context.request, fixture.createdIds);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
ensureAuthenticated,
|
||||
prepareTempTreeFixture,
|
||||
openDocument,
|
||||
requireMnoteWebSmokeBaseUrl,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function openRuntimeDebug(page, fixture) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.goto(
|
||||
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let fixture = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
fixture = await prepareTempTreeFixture(context.request);
|
||||
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
||||
await openRuntimeDebug(page, fixture);
|
||||
|
||||
const firstTextarea = page.locator("textarea[data-block-input-id]").first();
|
||||
await firstTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await firstTextarea.fill("AlphaBeta", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await firstTextarea.evaluate((node) => {
|
||||
node.setSelectionRange(5, 5);
|
||||
});
|
||||
await firstTextarea.press("Enter", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const textareas = page.locator("textarea[data-block-input-id]");
|
||||
await textareas.nth(1).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert((await textareas.count()) === 2, "Enter 拆块后未出现第二个输入块");
|
||||
assert((await textareas.nth(0).inputValue()) === "Alpha", "拆块后首块文本不正确");
|
||||
assert((await textareas.nth(1).inputValue()) === "Beta", "拆块后次块文本不正确");
|
||||
|
||||
await textareas.nth(1).press("Tab", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(".editor-row-meta").nth(1).getByText("depth=1").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await textareas.nth(1).press("Shift+Tab", { timeout: UI_TIMEOUT_MS });
|
||||
await page.locator(".editor-row-meta").nth(1).getByText("depth=0").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await textareas.nth(1).evaluate((node) => {
|
||||
node.setSelectionRange(0, 0);
|
||||
});
|
||||
await textareas.nth(1).press("Backspace", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
assert((await page.locator("textarea[data-block-input-id]").count()) === 1, "合并后块数量未回到 1");
|
||||
assert(
|
||||
(await page.locator("textarea[data-block-input-id]").first().inputValue()) === "AlphaBeta",
|
||||
"Backspace 合并后文本不正确",
|
||||
);
|
||||
const saveStatus = ((await page.locator("#editor-save-status").textContent()) || "").trim().toLowerCase();
|
||||
assert(
|
||||
saveStatus === "saved" || saveStatus === "saving" || saveStatus === "idle",
|
||||
`结构事务后保存状态异常:${saveStatus}`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: fixture.workspaceId,
|
||||
documentId: fixture.parentId,
|
||||
saveStatus,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture) {
|
||||
try {
|
||||
await cleanupDocuments(context.request, fixture.createdIds);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
ensureAuthenticated,
|
||||
prepareTempTreeFixture,
|
||||
openDocument,
|
||||
requireMnoteWebSmokeBaseUrl,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function openRuntimeDebug(page, fixture) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.goto(
|
||||
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let fixture = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
fixture = await prepareTempTreeFixture(context.request);
|
||||
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
||||
await openRuntimeDebug(page, fixture);
|
||||
|
||||
const textarea = page.locator("textarea[data-block-input-id]").first();
|
||||
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await textarea.fill("Command", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("#editor-command-slash").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-slash-action="heading"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("#editor-command-page-ref").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator("#editor-command-block-ref").click({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("#editor-save-status").getByText("saved").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const value = await textarea.inputValue();
|
||||
assert(
|
||||
value.includes("[[page]]") && value.includes("((block))"),
|
||||
`引用 token 未写入真实输入器:${value}`,
|
||||
);
|
||||
|
||||
const title = (await page.locator(".editor-row-title").first().textContent()) || "";
|
||||
assert(title.includes("Heading"), `slash 切块后未变成 heading:${title}`);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: fixture.workspaceId,
|
||||
documentId: fixture.parentId,
|
||||
value,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture) {
|
||||
try {
|
||||
await cleanupDocuments(context.request, fixture.createdIds);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
cleanupDocuments,
|
||||
ensureAuthenticated,
|
||||
prepareTempTreeFixture,
|
||||
openDocument,
|
||||
requireMnoteWebSmokeBaseUrl,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
async function openRuntimeDebug(page, fixture) {
|
||||
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
|
||||
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await page.goto(
|
||||
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let fixture = null;
|
||||
let caughtError = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
fixture = await prepareTempTreeFixture(context.request);
|
||||
await openDocument(page, fixture.workspaceId, fixture.parentId);
|
||||
await openRuntimeDebug(page, fixture);
|
||||
|
||||
const textarea = page.locator("textarea[data-block-input-id]").first();
|
||||
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await textarea.fill("persist runtime content", { timeout: UI_TIMEOUT_MS });
|
||||
|
||||
await page.locator("#editor-save-status").getByText("saved").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
const reloadedTextarea = page.locator("textarea[data-block-input-id]").first();
|
||||
await reloadedTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
assert(
|
||||
(await reloadedTextarea.inputValue()) === "persist runtime content",
|
||||
"刷新后未回放最近一次保存内容",
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${fixture.parentId}?workspaceId=${encodeURIComponent(fixture.workspaceId)}`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.getByRole("button", { name: "进入编辑" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(
|
||||
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
|
||||
"默认文档页不应继续挂载 runtime debug iframe",
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
workspaceId: fixture.workspaceId,
|
||||
documentId: fixture.parentId,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (fixture) {
|
||||
try {
|
||||
await cleanupDocuments(context.request, fixture.createdIds);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
"use strict";
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const {
|
||||
BASE_URL,
|
||||
UI_TIMEOUT_MS,
|
||||
assert,
|
||||
createTempDocument,
|
||||
ensureAuthenticated,
|
||||
purgeDocument,
|
||||
} = require("./tree-shell-smoke-helpers");
|
||||
|
||||
function readDocumentIdFromUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const match = parsed.pathname.match(/^\/documents\/([^/]+)$/);
|
||||
return match ? match[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRuntimeIslandDiagnostics(page) {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const observability = document.querySelector("[data-editor-host-observability]");
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror");
|
||||
const textareaCount = host?.querySelectorAll("textarea").length ?? 0;
|
||||
const contenteditableCount = host?.querySelectorAll(".editor-surface .ProseMirror[contenteditable]").length ?? 0;
|
||||
return {
|
||||
hostKind: host?.getAttribute("data-editor-host-kind") ?? null,
|
||||
runtimeStatus: host?.getAttribute("data-runtime-editor-status") ?? null,
|
||||
activeHostKind: observability?.getAttribute("data-editor-host-active") ?? null,
|
||||
observability: observability?.getAttribute("data-editor-host-observability") ?? null,
|
||||
editorTagName: editor instanceof HTMLElement ? editor.tagName : null,
|
||||
editorIsContentEditable: editor instanceof HTMLElement ? editor.isContentEditable : false,
|
||||
editorContentEditableAttr: editor instanceof HTMLElement ? editor.getAttribute("contenteditable") : null,
|
||||
editorCount: host?.querySelectorAll(".editor-surface .ProseMirror").length ?? 0,
|
||||
textareaCount,
|
||||
contenteditableCount,
|
||||
hostHTML: host?.innerHTML?.slice(0, 2000) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRuntimeIsland(page) {
|
||||
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
const observability = document.querySelector("[data-editor-host-observability]");
|
||||
const editor = host?.querySelector(".editor-surface .ProseMirror");
|
||||
return (
|
||||
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
|
||||
observability?.getAttribute("data-editor-host-active") === "leptos_tiptap_island" &&
|
||||
host?.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable === true
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostics, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const editorCount = await root.locator(".editor-surface .ProseMirror").count();
|
||||
const textareaCount = await root.locator("textarea").count();
|
||||
const contenteditableCount = await root.locator(".editor-surface .ProseMirror[contenteditable]").count();
|
||||
if (editorCount === 0 || contenteditableCount === 0 || textareaCount > 0) {
|
||||
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
|
||||
throw new Error(
|
||||
[
|
||||
editorCount === 0 ? "island 主编辑器根节点内缺少 `.editor-surface .ProseMirror` surface" : null,
|
||||
contenteditableCount === 0 ? "island 主编辑器 surface 未暴露真实 contenteditable" : null,
|
||||
textareaCount > 0 ? "island 主编辑器根节点内不应回退为 textarea" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(";") +
|
||||
`\n${JSON.stringify(diagnostics, null, 2)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSaved(page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-editor-host-kind="leptos_tiptap_island"]')
|
||||
?.getAttribute("data-runtime-editor-status") === "saved",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readEditorText(page) {
|
||||
return page.evaluate(() => {
|
||||
const editor = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
|
||||
);
|
||||
return editor?.textContent ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 960 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
let caughtError = null;
|
||||
let createdDocumentId = null;
|
||||
let createdWorkspaceId = null;
|
||||
|
||||
try {
|
||||
await ensureAuthenticated(page, context.request);
|
||||
const created = await createTempDocument(context.request, null);
|
||||
createdDocumentId = created.documentId;
|
||||
createdWorkspaceId = created.workspaceId;
|
||||
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}`,
|
||||
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
assert(
|
||||
page.url().includes(`/documents/${createdDocumentId}`),
|
||||
`未进入新建页面:${page.url()}`,
|
||||
);
|
||||
|
||||
await waitForRuntimeIsland(page);
|
||||
|
||||
const editor = page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
|
||||
.first();
|
||||
const text = `task108-island-${Date.now().toString().slice(-6)}`;
|
||||
|
||||
await editor.evaluate((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
await page.keyboard.type(text, { delay: 30 });
|
||||
await waitForSaved(page);
|
||||
assert((await readEditorText(page)).includes(text), "默认 runtime island 未写入文本");
|
||||
|
||||
await page.keyboard.press("Control+z");
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editorNode = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
|
||||
);
|
||||
return !(editorNode?.textContent ?? "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
await page.keyboard.press("Control+y");
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editorNode = document.querySelector(
|
||||
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
|
||||
);
|
||||
return (editorNode?.textContent ?? "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await waitForSaved(page);
|
||||
|
||||
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||
await waitForRuntimeIsland(page);
|
||||
assert((await readEditorText(page)).includes(text), "刷新后未回填 runtime island 保存内容");
|
||||
|
||||
await page.goto(
|
||||
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}&editorHost=blocknote`,
|
||||
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector("[data-editor-host-observability]")
|
||||
?.getAttribute("data-editor-host-active") === "blocknote",
|
||||
null,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const runtimeIslandCount = await page
|
||||
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
|
||||
.count();
|
||||
assert(runtimeIslandCount === 0, "显式 blocknote 回退下不应继续挂载 island 主编辑器");
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
baseUrl: BASE_URL,
|
||||
documentId: createdDocumentId,
|
||||
workspaceId: createdWorkspaceId,
|
||||
text,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
if (createdDocumentId) {
|
||||
try {
|
||||
await purgeDocument(context.request, createdDocumentId);
|
||||
} catch (cleanupError) {
|
||||
if (!caughtError) {
|
||||
caughtError = cleanupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel";
|
||||
|
||||
export default function DevAiAgentPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#04070f] px-4 py-6 text-white">
|
||||
<div className="mx-auto flex max-w-[1600px] flex-col gap-4">
|
||||
<div className="px-1">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE · Global AI Lab</div>
|
||||
<div className="mt-2 text-sm text-white/55">
|
||||
当前页面通过 <code className="rounded bg-white/10 px-1.5 py-0.5 text-white">/api/ai-agent/run</code> 使用 SSE
|
||||
运行全局工具编排。
|
||||
</div>
|
||||
</div>
|
||||
<AiAgentPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
|
||||
const defaultStats: DocumentStats = {
|
||||
wordCount: 0,
|
||||
characterCount: 0,
|
||||
blockCount: 0,
|
||||
todoTotal: 0,
|
||||
todoDone: 0,
|
||||
};
|
||||
|
||||
export default function PageOptionsPlaygroundPage() {
|
||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||
const [options, setOptions] = useState<PageOptionsState>(defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(defaultStats);
|
||||
const documentId = "dev-page-options";
|
||||
const workspaceId = "dev-workspace";
|
||||
|
||||
const initialContent = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "h1",
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [{ type: "text", text: "标题一" }],
|
||||
},
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
props: {},
|
||||
content: [{ type: "text", text: "这是用于回归测试页面选项的示例段落。" }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((key: BooleanPageOptionKey) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const setPageFont = useCallback((font: PageFont) => setOptions((prev) => ({ ...prev, pageFont: font })), []);
|
||||
const setLayoutDensity = useCallback(
|
||||
(density: PageLayoutDensity) => setOptions((prev) => ({ ...prev, layoutDensity: density })),
|
||||
[],
|
||||
);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-[calc(100vh-64px)] overflow-hidden bg-wolai-bg",
|
||||
options.pageFont === "song" && "wolai-page-font-song",
|
||||
options.pageFont === "kai" && "wolai-page-font-kai",
|
||||
options.layoutDensity === "compact" && "wolai-page-density-compact",
|
||||
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
Dev Playground:用于 Playwright 回归页面选项(不写入后端)
|
||||
</div>
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className={pageRootClass}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="text-3xl font-semibold text-wolai-text-primary">页面选项回归测试</div>
|
||||
<p className="mt-1 text-sm text-wolai-text-secondary">该页面不会保存任何更改。</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent as unknown}
|
||||
pageOptions={options}
|
||||
readOnly={false}
|
||||
onStatsChange={setStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageOptionsSidebar
|
||||
documentId={documentId}
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={setPageFont}
|
||||
onSetLayoutDensity={setLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={() => window.alert("该页面为 Dev Playground,不写入后端")}
|
||||
onClearEmbedDefault={() => setOptions((prev) => ({ ...prev, embedDefaultBlockId: null }))}
|
||||
onExport={() => window.alert("该页面为 Dev Playground,不提供导出")}
|
||||
onOpenHistory={() => window.alert("该页面为 Dev Playground,不提供历史")}
|
||||
onOpenComments={() => window.alert("该页面为 Dev Playground,不提供评论")}
|
||||
onUndo={() => editorBridge?.undo?.()}
|
||||
onRedo={() => editorBridge?.redo?.()}
|
||||
onDeletePage={() => window.alert("该页面为 Dev Playground,不提供删除")}
|
||||
onOpenMoveEmbedPicker={() => window.alert("该页面为 Dev Playground,不提供移动/嵌入")}
|
||||
onCopyPageLink={() => window.alert("该页面为 Dev Playground,不提供复制链接")}
|
||||
onCopyPageReference={() => window.alert("该页面为 Dev Playground,不提供引用")}
|
||||
onAddToTemplates={() => window.alert("该页面为 Dev Playground,不提供模板")}
|
||||
/>
|
||||
</div>
|
||||
</ImagePickerProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,166 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { extractBlockText } from "@/lib/blocks";
|
||||
|
||||
type RemoteBlock = {
|
||||
id: string;
|
||||
type?: string;
|
||||
props?: Record<string, unknown>;
|
||||
content?: unknown;
|
||||
children?: unknown;
|
||||
};
|
||||
|
||||
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
|
||||
|
||||
export const blockReferenceBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "blockReference",
|
||||
propSchema: {
|
||||
sourceDocumentId: { default: "" },
|
||||
targetBlockId: { default: "" },
|
||||
display: { default: "embed" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => <BlockReferenceContent block={block as any} />,
|
||||
}),
|
||||
)();
|
||||
|
||||
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
|
||||
const router = useRouter();
|
||||
const sourceDocumentId = block.props.sourceDocumentId;
|
||||
const targetBlockId = block.props.targetBlockId;
|
||||
|
||||
const [remote, setRemote] = useState<RemoteBlock | null>(null);
|
||||
const [textDraft, setTextDraft] = useState<string>("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceDocumentId || !targetBlockId) {
|
||||
setStatus("error");
|
||||
setError("引用信息不完整");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
setError("");
|
||||
setRemote(null);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
|
||||
{ method: "GET", credentials: "include" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
throw new Error(payload?.error ?? "获取引用块失败");
|
||||
}
|
||||
const json = await res.json();
|
||||
const next = (json?.block ?? null) as RemoteBlock | null;
|
||||
if (!cancelled) {
|
||||
setRemote(next);
|
||||
if (next && isTextBlock(next)) {
|
||||
setTextDraft(extractBlockText(next as any));
|
||||
}
|
||||
setStatus("idle");
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setError(e instanceof Error ? e.message : "获取引用块失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sourceDocumentId, targetBlockId]);
|
||||
|
||||
const openSource = useCallback(() => {
|
||||
if (sourceDocumentId) {
|
||||
router.push(`/documents/${sourceDocumentId}`);
|
||||
}
|
||||
}, [router, sourceDocumentId]);
|
||||
|
||||
const saveText = useCallback(async () => {
|
||||
if (!remote || !canEdit) return;
|
||||
const nextBlock: RemoteBlock = {
|
||||
...remote,
|
||||
id: remote.id,
|
||||
content: [{ type: "text", text: textDraft }],
|
||||
};
|
||||
const res = await fetch("/api/blocks/patch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const msg = payload?.error ?? "同步编辑失败";
|
||||
if (typeof window !== "undefined") window.alert(msg);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
|
||||
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
|
||||
onMouseDown={(e) => {
|
||||
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>嵌入引用</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
|
||||
打开原块
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status === "loading" ? (
|
||||
<div className="text-sm text-gray-400">加载中...</div>
|
||||
) : status === "error" ? (
|
||||
<div className="text-sm text-red-600">{error}</div>
|
||||
) : !remote ? (
|
||||
<div className="text-sm text-gray-400">引用块不存在</div>
|
||||
) : canEdit ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
|
||||
rows={3}
|
||||
value={textDraft}
|
||||
onChange={(e) => setTextDraft(e.target.value)}
|
||||
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
|
||||
同步到原块
|
||||
</Button>
|
||||
<span className="text-[11px] text-gray-400">MVP:仅支持段落/标题纯文本同步</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700">
|
||||
<div className="mb-1 text-xs text-gray-400">当前块类型:{remote.type ?? "unknown"}</div>
|
||||
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
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, Trash, 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 { 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,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
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 }: 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"
|
||||
? (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 officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
const resolveDocumentId = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const [, tail] = window.location.pathname.split("/documents/");
|
||||
if (tail) {
|
||||
const id = tail.split(/[/?#]/)[0];
|
||||
if (id) return id;
|
||||
}
|
||||
}
|
||||
return (block.props as { documentId?: string })?.documentId || "";
|
||||
}, [block.props]);
|
||||
const extension = useMemo(() => {
|
||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||
return match?.[1] ?? "";
|
||||
}, [block.props.fileName, fileUrl]);
|
||||
const isOfficeDoc = useMemo(
|
||||
() =>
|
||||
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
||||
extension,
|
||||
),
|
||||
[extension],
|
||||
);
|
||||
|
||||
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",
|
||||
documentId: resolveDocumentId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 resolveAssetId = async () => {
|
||||
const direct = (block.props as { assetId?: string })?.assetId;
|
||||
if (direct) return direct;
|
||||
|
||||
// 说明:历史数据/迁移场景下,media block 可能丢失 assetId,导致:
|
||||
// - PDF 打开拿到的不是原文件(旧链接过期/返回 HTML)
|
||||
// - OnlyOffice callback 缺少 assetId,进而“不能保存”
|
||||
// 这里尝试通过 documentId + fileName 在 media_assets 中反查 assetId。
|
||||
const docId = (block.props as { documentId?: string })?.documentId || resolveDocumentId();
|
||||
const name = String(block.props.fileName || block.props.caption || "").trim();
|
||||
if (!docId || !name) return "";
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/media/by-document?documentId=${encodeURIComponent(docId)}&limit=500`,
|
||||
);
|
||||
if (!res.ok) return "";
|
||||
const payload = (await res.json().catch(() => null)) as { items?: Array<{ id?: string; file_name?: string | null }> } | null;
|
||||
const items = Array.isArray(payload?.items) ? payload!.items! : [];
|
||||
const hit = items.find((it) => String(it.file_name || "") === name);
|
||||
return hit?.id ? String(hit.id) : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLatestFileUrl = async () => {
|
||||
if (!fileUrl) return "";
|
||||
const assetId = await resolveAssetId();
|
||||
if (!assetId) return fileUrl;
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
||||
if (!res.ok) return fileUrl;
|
||||
const payload = (await res.json().catch(() => null)) as { signedUrl?: string } | null;
|
||||
return payload?.signedUrl || fileUrl;
|
||||
} catch {
|
||||
return fileUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const viewOriginal = async () => {
|
||||
const url = await resolveLatestFileUrl();
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const openWithOnlyOffice = async () => {
|
||||
if (!fileUrl) return;
|
||||
if (!officeBase) {
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
return;
|
||||
}
|
||||
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
|
||||
try {
|
||||
const assetId = await resolveAssetId();
|
||||
const res = assetId
|
||||
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
|
||||
: await fetch(
|
||||
`/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent(
|
||||
displayFileName,
|
||||
)}&for=onlyoffice`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "生成签名链接失败");
|
||||
}
|
||||
const { signedUrl } = (await res.json()) as { signedUrl: string };
|
||||
const target = new URL("/onlyoffice", window.location.origin);
|
||||
target.searchParams.set("fileUrl", signedUrl);
|
||||
target.searchParams.set("fileName", displayFileName);
|
||||
target.searchParams.set("fileType", extension || "docx");
|
||||
const docId = resolveDocumentId();
|
||||
if (docId) {
|
||||
target.searchParams.set("documentId", docId);
|
||||
}
|
||||
if (assetId) {
|
||||
target.searchParams.set("assetId", assetId);
|
||||
}
|
||||
target.searchParams.set("mode", currentDocReadOnly ? "view" : "edit");
|
||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentId = useCurrentDocumentStore((state) => state.documentId);
|
||||
const currentDisableDownload = useCurrentDocumentStore((state) => state.disableDownload);
|
||||
const resolvedDocIdForRestriction = resolveDocumentId();
|
||||
const downloadDisabled =
|
||||
Boolean(currentDisableDownload) &&
|
||||
Boolean(resolvedDocIdForRestriction) &&
|
||||
String(currentDocumentId ?? "") === String(resolvedDocIdForRestriction);
|
||||
|
||||
const downloadAsset = async () => {
|
||||
if (downloadDisabled) {
|
||||
window.alert("该页面已禁止下载");
|
||||
return;
|
||||
}
|
||||
const url = await resolveLatestFileUrl();
|
||||
if (!url) return;
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
if (!assetId) {
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const docId = resolveDocumentId();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(docId);
|
||||
};
|
||||
|
||||
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={browserThumbUrl || undefined}
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
>
|
||||
<source src={browserFileUrl} 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={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
||||
</audio>
|
||||
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
void downloadAsset();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
void downloadAsset();
|
||||
}
|
||||
}
|
||||
}}
|
||||
data-testid="wolai-media-file-row"
|
||||
className="group flex h-[26px] items-center gap-2 rounded-[4px] border border-transparent bg-transparent px-2 outline-none transition-colors duration-200 cursor-pointer select-none hover:border-[#E9E9E8] hover:bg-[#F7F7F5] focus:outline-none focus-visible:outline-none"
|
||||
>
|
||||
<span className={cn("w-5 h-5 flex-none flex items-center justify-center", getIconColor())}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-row items-center gap-2 overflow-hidden">
|
||||
<span className="min-w-0 flex-1 truncate text-[14px] text-[#37352F] font-normal">
|
||||
{displayFileName}
|
||||
</span>
|
||||
{block.props.fileSize ? (
|
||||
<span className="shrink-0 text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-media-file-download"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
aria-label={downloadDisabled ? "已禁止下载" : "下载"}
|
||||
title={downloadDisabled ? "已禁止下载" : "下载"}
|
||||
disabled={downloadDisabled}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-media-file-more"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
aria-label="更多操作"
|
||||
title="更多操作"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && <DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>}
|
||||
<DropdownMenuItem onClick={handleLink}>{block.props.linkUrl ? "编辑链接" : "添加链接"}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void viewOriginal();
|
||||
}}
|
||||
>
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
{isOfficeDoc && <DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>}
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteAsset}>删除</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return (
|
||||
<img
|
||||
src={browserThumbUrl || browserFileUrl}
|
||||
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,
|
||||
},
|
||||
!downloadDisabled
|
||||
? {
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
void downloadAsset();
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <Trash className="h-4 w-4" />,
|
||||
onClick: handleDeleteAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
return (
|
||||
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
|
||||
<div
|
||||
className="wolai-media__canvas"
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
onDoubleClick={() => {
|
||||
if (assetType === "file" && isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else if (assetType === "file") {
|
||||
void viewOriginal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
{assetType !== "file" && (
|
||||
<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={() => {
|
||||
void viewOriginal();
|
||||
}}
|
||||
>
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</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" },
|
||||
documentId: { default: "", 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,256 @@
|
||||
"use client";
|
||||
|
||||
import { BlockNoteEditor, Block } from "@blocknote/core";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
|
||||
const DEFAULT_WIDTH = 960;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 420;
|
||||
const MAX_WIDTH = 1400;
|
||||
const MIN_HEIGHT = 320;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
type ResizeHandle =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
const handleMapping: Record<
|
||||
ResizeHandle,
|
||||
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
|
||||
> = {
|
||||
left: { horizontal: "left" },
|
||||
right: { horizontal: "right" },
|
||||
top: { vertical: "top" },
|
||||
bottom: { vertical: "bottom" },
|
||||
"top-left": { horizontal: "left", vertical: "top" },
|
||||
"top-right": { horizontal: "right", vertical: "top" },
|
||||
"bottom-left": { horizontal: "left", vertical: "bottom" },
|
||||
"bottom-right": { horizontal: "right", vertical: "bottom" },
|
||||
};
|
||||
|
||||
// 占位符组件:在紧凑模式下渲染表格块
|
||||
const OnlineTableBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: any) => {
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
|
||||
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
|
||||
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
|
||||
const [draftSize, setDraftSize] = useState({
|
||||
width: storedWidth ?? DEFAULT_WIDTH,
|
||||
height: storedHeight ?? DEFAULT_HEIGHT,
|
||||
});
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const committedSize = useMemo(
|
||||
() => ({
|
||||
width: clamp(storedWidth ?? DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH),
|
||||
height: clamp(storedHeight ?? DEFAULT_HEIGHT, MIN_HEIGHT, MAX_HEIGHT),
|
||||
}),
|
||||
[storedHeight, storedWidth],
|
||||
);
|
||||
|
||||
const commitSize = useCallback(
|
||||
(next: { width: number; height: number }) => {
|
||||
setDraftSize(next);
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
...block.props,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
},
|
||||
});
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
// 阶段三:实现双击/按钮进入全屏编辑
|
||||
const handleFullScreen = () => {
|
||||
if (openTableFullScreen) {
|
||||
openTableFullScreen(tableId);
|
||||
} else {
|
||||
console.error("Editor bridge not ready or openTableFullScreen missing.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [block.id, editor]);
|
||||
|
||||
const startResize = useCallback(
|
||||
(handle: ResizeHandle) => (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startWidth = committedSize.width;
|
||||
const startHeight = committedSize.height;
|
||||
let nextWidth = startWidth;
|
||||
let nextHeight = startHeight;
|
||||
const axes = handleMapping[handle];
|
||||
setActiveHandle(handle);
|
||||
setDraftSize({ width: startWidth, height: startHeight });
|
||||
document.body.style.userSelect = "none";
|
||||
const cursor =
|
||||
axes.horizontal && axes.vertical
|
||||
? axes.horizontal === "left"
|
||||
? axes.vertical === "top"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
: axes.vertical === "top"
|
||||
? "nesw-resize"
|
||||
: "nwse-resize"
|
||||
: axes.horizontal
|
||||
? "ew-resize"
|
||||
: "ns-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
if (axes.horizontal === "left") {
|
||||
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else if (axes.horizontal === "right") {
|
||||
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else {
|
||||
nextWidth = startWidth;
|
||||
}
|
||||
|
||||
if (axes.vertical === "top") {
|
||||
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else if (axes.vertical === "bottom") {
|
||||
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else {
|
||||
nextHeight = startHeight;
|
||||
}
|
||||
setDraftSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
setActiveHandle(null);
|
||||
commitSize({
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[commitSize, committedSize.height, committedSize.width],
|
||||
);
|
||||
|
||||
const size = useMemo(() => {
|
||||
const src = activeHandle ? draftSize : committedSize;
|
||||
return {
|
||||
width: clamp(src.width, MIN_WIDTH, MAX_WIDTH),
|
||||
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
|
||||
};
|
||||
}, [activeHandle, committedSize, draftSize]);
|
||||
|
||||
const handleClass = (handle: ResizeHandle) =>
|
||||
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
|
||||
activeHandle === handle ? "is-dragging" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto" contentEditable={false}>
|
||||
<div
|
||||
className="online-table-block group relative mx-auto"
|
||||
style={{ width: size.width, minWidth: MIN_WIDTH }}
|
||||
>
|
||||
<CompactTablePreview
|
||||
tableId={tableId}
|
||||
onFullScreen={handleFullScreen}
|
||||
onDelete={handleDelete}
|
||||
height={size.height}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向左拖拽以调整宽度"
|
||||
className={handleClass("left")}
|
||||
onMouseDown={startResize("left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向右拖拽以调整宽度"
|
||||
className={handleClass("right")}
|
||||
onMouseDown={startResize("right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向上拖拽以调整高度"
|
||||
className={handleClass("top")}
|
||||
onMouseDown={startResize("top")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向下拖拽以调整高度"
|
||||
className={handleClass("bottom")}
|
||||
onMouseDown={startResize("bottom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-left")}
|
||||
onMouseDown={startResize("top-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-right")}
|
||||
onMouseDown={startResize("top-right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-left")}
|
||||
onMouseDown={startResize("bottom-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-right")}
|
||||
onMouseDown={startResize("bottom-right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Block Spec 定义
|
||||
export const onlineTableBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "onlineTable",
|
||||
propSchema: {
|
||||
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
|
||||
title: { default: "未命名表格" },
|
||||
width: { default: DEFAULT_WIDTH },
|
||||
height: { default: DEFAULT_HEIGHT },
|
||||
},
|
||||
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
|
||||
},
|
||||
{
|
||||
render: (props) => <OnlineTableBlockComponent {...props} />,
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import { RiFileTextFill } from "react-icons/ri";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const normalizeTitle = (value?: string | null) => {
|
||||
if (!value || !value.trim()) {
|
||||
return "未命名页面";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const PageReferenceContent = ({
|
||||
pageId,
|
||||
title,
|
||||
asChildPage,
|
||||
}: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
asChildPage: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
|
||||
// 页面引用的标题会由编辑器同步更新 block.props.title。
|
||||
const resolvedTitle = normalizeTitle(title);
|
||||
|
||||
const navigate = () => {
|
||||
if (pageId) {
|
||||
router.push(`/documents/${pageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-child-page={asChildPage ? "true" : "false"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={navigate}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key === "Enter" || event.key === " ") && pageId) {
|
||||
event.preventDefault();
|
||||
navigate();
|
||||
}
|
||||
}}
|
||||
className="group mt-2 inline-flex items-center gap-1.5 rounded-[3px] px-1.5 py-0.5 text-[#37352F] hover:bg-[rgba(55,53,47,0.08)] transition-colors duration-150"
|
||||
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
|
||||
>
|
||||
<RiFileTextFill className="w-4 h-4 text-[#9B9A97] group-hover:text-[#37352F] transition-colors" aria-hidden />
|
||||
<span className="text-[15px] font-medium leading-normal">{resolvedTitle}</span>
|
||||
<span className="text-[13px] text-[#9B9A97] opacity-0 group-hover:opacity-100 ml-2">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const pageReferenceBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "pageReference",
|
||||
propSchema: {
|
||||
pageId: { default: "" },
|
||||
title: { default: "未命名页面" },
|
||||
asChildPage: { default: false },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => (
|
||||
<PageReferenceContent
|
||||
pageId={block.props.pageId}
|
||||
title={block.props.title}
|
||||
asChildPage={Boolean((block.props as any).asChildPage)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
)();
|
||||
@@ -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,753 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { Block, PartialBlock } from "@blocknote/core";
|
||||
import {
|
||||
BlockColorsItem,
|
||||
SideMenu,
|
||||
TableColumnHeaderItem,
|
||||
TableRowHeaderItem,
|
||||
useBlockNoteEditor,
|
||||
useComponentsContext,
|
||||
type DragHandleMenuProps,
|
||||
type SideMenuProps,
|
||||
} from "@blocknote/react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { createChildDocumentCommand, deleteDocumentCommand } from "@/lib/documents/tree-command-client";
|
||||
|
||||
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;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const FourDotHandleIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
|
||||
<circle cx="6" cy="6" r="1.2" />
|
||||
<circle cx="10" cy="6" r="1.2" />
|
||||
<circle cx="6" cy="10" r="1.2" />
|
||||
<circle cx="10" cy="10" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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 clearMindmapAutosaveCache = (targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const prefix = "wolai-mindmap-autosave-";
|
||||
const targetPrefix = `${prefix}${targetDocumentId}`;
|
||||
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (!k) continue;
|
||||
if (mindmapId) {
|
||||
if (k === directKey) keys.push(k);
|
||||
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
keys.forEach((k) => window.localStorage.removeItem(k));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const markMindmapDeleting = (targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__wolaiMindmapDeletingKeys?: Set<string>;
|
||||
};
|
||||
if (!w.__wolaiMindmapDeletingKeys) {
|
||||
w.__wolaiMindmapDeletingKeys = new Set<string>();
|
||||
}
|
||||
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
|
||||
w.__wolaiMindmapDeletingKeys.add(key);
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
w.__wolaiMindmapDeletingKeys?.delete(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 8000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||||
const router = useRouter();
|
||||
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
|
||||
|
||||
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 deleteDocumentCommand({
|
||||
documentId: pageId,
|
||||
workspaceId,
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
emitDocumentsChanged(pageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
router.refresh();
|
||||
}, [block, editor, router, workspaceId]);
|
||||
|
||||
const handleDeleteBlock = useCallback(async () => {
|
||||
if (block.type === "pageReference") {
|
||||
void removePageReference();
|
||||
return;
|
||||
}
|
||||
if (block.type === "onlineTable") {
|
||||
const tableId = block.props.tableId as string | undefined;
|
||||
if (tableId) {
|
||||
try {
|
||||
await deleteOnlineTable(tableId);
|
||||
} catch (error) {
|
||||
console.error("删除在线表格失败", error);
|
||||
window.alert("删除在线表格失败,请稍后重试");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||||
emitAssetsChanged(currentDocumentId);
|
||||
}
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
if (block.type === "media") {
|
||||
const assetId = block.props.assetId as string | undefined;
|
||||
if (assetId) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
emitAssetsChanged(currentDocumentId);
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
if (block.type === "mindmap") {
|
||||
// 关键:必须先标记“删除中”,避免 MindmapBlock 卸载清理把数据 POST 回去导致“删除后复活”。
|
||||
markMindmapDeleting(currentDocumentId, block.id);
|
||||
clearMindmapAutosaveCache(currentDocumentId, block.id);
|
||||
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除思维导图失败");
|
||||
return;
|
||||
}
|
||||
emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]);
|
||||
// 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错
|
||||
try {
|
||||
editor.removeBlocks([block.id]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [block, currentDocumentId, editor, removePageReference]);
|
||||
|
||||
const turnToPage = useCallback(async () => {
|
||||
try {
|
||||
const payload = await createChildDocumentCommand({
|
||||
parentId: currentDocumentId,
|
||||
title: extractText(block),
|
||||
blocks: [block],
|
||||
});
|
||||
|
||||
editor.replaceBlocks(
|
||||
[block.id],
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId: payload.pageId, title: payload.title, asChildPage: true },
|
||||
} as PartialBlock<CustomBlockSchema>,
|
||||
],
|
||||
);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error("块转页面失败", error);
|
||||
}
|
||||
}, [block, currentDocumentId, editor, router]);
|
||||
|
||||
const handleMoveEmbedPick = useCallback(
|
||||
async (mode: "move" | "embed", targetDocumentId: string | null) => {
|
||||
if (!targetDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "embed" && targetDocumentId === currentDocumentId) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert("禁止嵌入到当前页面");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceDocumentId: currentDocumentId,
|
||||
blockId: block.id,
|
||||
targetDocumentId,
|
||||
position: "end",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message =
|
||||
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "move") {
|
||||
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
|
||||
try {
|
||||
editor.removeBlocks([block.id]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert("已在目标页面末尾插入嵌入引用块");
|
||||
}
|
||||
},
|
||||
[block.id, currentDocumentId, editor, router],
|
||||
);
|
||||
|
||||
const moveOrEmbedBlock = useCallback(async () => {
|
||||
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
|
||||
openPicker({
|
||||
workspaceId,
|
||||
defaultMode: "move",
|
||||
modes: ["move", "embed"],
|
||||
allowRoot: false,
|
||||
excludeIds: [currentDocumentId],
|
||||
onPick: handleMoveEmbedPick,
|
||||
});
|
||||
return;
|
||||
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
|
||||
|
||||
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: "quote" },
|
||||
{ label: "代码片段", type: "codeBlock" },
|
||||
],
|
||||
[turnToPage],
|
||||
);
|
||||
|
||||
const convertBlock = useCallback(
|
||||
(option: ConvertOption) => {
|
||||
if (option.action) {
|
||||
option.action();
|
||||
return;
|
||||
}
|
||||
if (!option.type) return;
|
||||
editor.updateBlock(block as any, {
|
||||
type: option.type as any,
|
||||
props: (option.props ?? {}) as any,
|
||||
} as any);
|
||||
},
|
||||
[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={() => {
|
||||
if (!workspaceId) {
|
||||
window.alert("缺少 workspaceId,无法打开评论");
|
||||
return;
|
||||
}
|
||||
openCommentsForBlock({ workspaceId, documentId: currentDocumentId, blockId: block.id });
|
||||
}}
|
||||
>
|
||||
评论
|
||||
</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;
|
||||
workspaceId: string | null;
|
||||
unresolvedCommentCountByBlockId?: Record<string, number>;
|
||||
};
|
||||
|
||||
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const {
|
||||
editor,
|
||||
block,
|
||||
blockDragStart,
|
||||
blockDragEnd,
|
||||
freezeMenu,
|
||||
unfreezeMenu,
|
||||
currentDocumentId,
|
||||
workspaceId,
|
||||
unresolvedCommentCountByBlockId,
|
||||
} = props;
|
||||
const [insertHovered, setInsertHovered] = useState<null | "top" | "bottom">(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [activeCursorBlockId, setActiveCursorBlockId] = useState<string | null>(null);
|
||||
const hoverAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuFrozenRef = useRef(false);
|
||||
|
||||
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
|
||||
// BlockNote 的 SideMenu 默认参考点更偏“块顶部”,因此这里通过扩大 hover 区域并把手柄定位到附件行中线来对齐。
|
||||
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
|
||||
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
|
||||
const hoverPadPx = 14;
|
||||
const lineGapPx = 3;
|
||||
const insertBtnSizePx = 16;
|
||||
const handleBtnSizePx = 22;
|
||||
const unresolvedCount = unresolvedCommentCountByBlockId?.[block.id] ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
setActiveCursorBlockId(cursor?.block?.id ?? null);
|
||||
};
|
||||
update();
|
||||
return editor.onSelectionChange(update);
|
||||
}, [editor]);
|
||||
|
||||
const setFrozen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (menuFrozenRef.current === next) return;
|
||||
menuFrozenRef.current = next;
|
||||
if (next) {
|
||||
freezeMenu();
|
||||
} else {
|
||||
unfreezeMenu();
|
||||
}
|
||||
},
|
||||
[freezeMenu, unfreezeMenu],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:Win+Shift+S 截图会触发窗口失焦/可见性变化;若用右键取消,
|
||||
// 有些环境下不会触发正常的 mouseleave,导致手柄状态卡死(看起来像“消失”)。
|
||||
// 这里在失焦/隐藏时强制解除冻结并重置 hover 状态。
|
||||
const reset = () => {
|
||||
setHovering(false);
|
||||
setMenuOpen(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
};
|
||||
|
||||
const onBlur = () => reset();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("blur", onBlur);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [setFrozen]);
|
||||
|
||||
const insertParagraph = useCallback(
|
||||
(position: "before" | "after") => {
|
||||
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
|
||||
if (inserted) {
|
||||
editor.setTextCursorPosition(inserted as any);
|
||||
editor.focus();
|
||||
}
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
const stop = (e: ReactMouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const paragraphPlainText = useMemo(() => {
|
||||
if (block.type !== "paragraph") return null;
|
||||
const content = Array.isArray(block.content) ? (block.content as any[]) : [];
|
||||
const text = content
|
||||
.map((node) =>
|
||||
node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""
|
||||
)
|
||||
.join("");
|
||||
return text;
|
||||
}, [block]);
|
||||
|
||||
const isEmptyParagraph = block.type === "paragraph" && (paragraphPlainText ?? "").trim().length === 0;
|
||||
const showEmptyPlus = isEmptyParagraph && (activeCursorBlockId === block.id || hovering || menuOpen);
|
||||
|
||||
const openSlashMenuFromEmptyPlus = (e: ReactMouseEvent) => {
|
||||
stop(e);
|
||||
if (!isEmptyParagraph) return;
|
||||
try {
|
||||
editor.setTextCursorPosition(block as any, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
editor.focus();
|
||||
// 说明:按 Wolai 手感,点击“+”等同于在空行输入 “/” 打开斜杠菜单。
|
||||
// deleteTriggerCharacter=true 会把 “/” 写入编辑器,并在选择条目后由插件清理掉。
|
||||
editor.openSuggestionMenu("/", { deleteTriggerCharacter: true, ignoreQueryLength: true });
|
||||
};
|
||||
|
||||
const forceShowInsertButtons = block.type === "mindmap" || block.type === "onlineTable";
|
||||
// 说明:思维导图/在线表格等嵌入块内部可能接管鼠标事件,导致 hover 状态不稳定。
|
||||
// 对这些块直接常驻显示插入控件,避免“看不到横杠/加号”。
|
||||
const showInsertButtons = !showEmptyPlus && (forceShowInsertButtons || hovering || menuOpen);
|
||||
const handleCenterY = hoverPadPx + rowHeightPx / 2;
|
||||
// 说明:插入按钮的位置必须“跟着手柄走”,不能依赖容器上下边界。
|
||||
// 否则对于思维导图/在线表格等高块,容器可能被撑高,导致按钮跑到块底部。
|
||||
// 说明:插入按钮不能与六点手柄发生重叠,否则 hover/click 会被手柄拦截(表现为“看得见但点不到/hover 没反应”)。
|
||||
// 这里用“手柄按钮尺寸 + 插入按钮尺寸 + 间距”计算中心距,确保永不重叠。
|
||||
const insertDistPx = handleBtnSizePx / 2 + insertBtnSizePx / 2 + lineGapPx;
|
||||
const insertBeforeTopPx = handleCenterY - insertDistPx - insertBtnSizePx / 2;
|
||||
const insertAfterTopPx = handleCenterY + insertDistPx - insertBtnSizePx / 2;
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root
|
||||
onOpenChange={(open: boolean) => {
|
||||
setMenuOpen(open);
|
||||
setFrozen(open || hovering);
|
||||
}}
|
||||
position={"left"}
|
||||
>
|
||||
<div
|
||||
ref={hoverAreaRef}
|
||||
data-testid="wolai-handle-area"
|
||||
className="relative w-7 overflow-visible"
|
||||
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHovering(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(menuOpen || false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-before"
|
||||
title="在上方插入块"
|
||||
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: insertBeforeTopPx }}
|
||||
onPointerEnter={() => setInsertHovered("top")}
|
||||
onPointerLeave={() => {
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering) setInsertHovered(null);
|
||||
});
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("before");
|
||||
}}
|
||||
>
|
||||
{insertHovered === "top"
|
||||
? <Plus className="h-3.5 w-3.5" />
|
||||
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 z-[2147483647] -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ top: hoverPadPx + rowHeightPx / 2 }}
|
||||
>
|
||||
{showEmptyPlus ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-empty-plus"
|
||||
aria-label="打开斜杠命令"
|
||||
className="flex h-[22px] w-[22px] items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
onMouseDown={stop}
|
||||
onClick={openSlashMenuFromEmptyPlus}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{!showEmptyPlus ? (
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave;
|
||||
// 这里用 :hover 兜底,避免插入按钮一闪而过。
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering && !menuOpen) {
|
||||
setHovering(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Components.SideMenu.Button
|
||||
label="块操作"
|
||||
draggable={true}
|
||||
onDragStart={(e) => blockDragStart(e, block)}
|
||||
onDragEnd={blockDragEnd}
|
||||
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
|
||||
icon={
|
||||
<span className="relative inline-flex">
|
||||
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
|
||||
{unresolvedCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
|
||||
{unresolvedCount > 9 ? "9+" : unresolvedCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-after"
|
||||
title="在下方插入块"
|
||||
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: insertAfterTopPx }}
|
||||
onPointerEnter={() => setInsertHovered("bottom")}
|
||||
onPointerLeave={() => {
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering) setInsertHovered(null);
|
||||
});
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("after");
|
||||
}}
|
||||
>
|
||||
{insertHovered === "bottom"
|
||||
? <Plus className="h-3.5 w-3.5" />
|
||||
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
|
||||
</Components.Generic.Menu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||
<SideMenu {...props}>
|
||||
<WolaiDragHandleWithInsert {...props} />
|
||||
</SideMenu>
|
||||
);
|
||||
@@ -0,0 +1,419 @@
|
||||
"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,
|
||||
Spline,
|
||||
Sparkles,
|
||||
SquareCheckBig,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind, MediaSelection } from "@/types/media";
|
||||
import { createOnlineTable } from "@/lib/online-table";
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
function insertOrUpdateBlockForSlashMenuCompat(
|
||||
editor: BlockNoteEditor<CustomBlockSchema>,
|
||||
partialBlock: unknown,
|
||||
) {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextType = (partialBlock as { type?: unknown })?.type;
|
||||
const shouldAppendParagraph = nextType === "mindmap";
|
||||
|
||||
const content = Array.isArray(referenceBlock.content) ? referenceBlock.content : [];
|
||||
const text = content
|
||||
.map((node) => (node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
const looksLikeSlashCommand = text === "" || text.startsWith("/");
|
||||
|
||||
if (referenceBlock.type === "paragraph" && looksLikeSlashCommand) {
|
||||
// 兼容默认 slash menu 行为:将当前段落“就地替换”为目标块类型,避免插入后又被 slash 菜单逻辑清理掉
|
||||
editor.updateBlock(referenceBlock, partialBlock as never);
|
||||
if (shouldAppendParagraph) {
|
||||
const inserted = editor.insertBlocks(
|
||||
[{ type: "paragraph" } as never],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
const paragraph = inserted[0];
|
||||
if (paragraph) {
|
||||
try {
|
||||
editor.setTextCursorPosition(paragraph, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldAppendParagraph) {
|
||||
const inserted = editor.insertBlocks(
|
||||
[partialBlock as never, { type: "paragraph" } as never],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
const paragraph = inserted[1] ?? inserted[inserted.length - 1];
|
||||
if (paragraph) {
|
||||
try {
|
||||
editor.setTextCursorPosition(paragraph, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
editor.insertBlocks([partialBlock as never], referenceBlock, "after");
|
||||
}
|
||||
|
||||
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) => {
|
||||
// 注意:不要把 cursor/referenceBlock 在 getItems 阶段“捕获”后长期复用。
|
||||
// Slash 菜单打开后,BlockNote 会持续更新光标与块对象;若使用陈旧引用,
|
||||
// 可能出现插入块“瞬间出现又消失/不落库”的现象(尤其是插入自定义块时)。
|
||||
const createTableItem: DefaultReactSuggestionItem = {
|
||||
title: "在线表格",
|
||||
group: "高级",
|
||||
aliases: ["online table", "bg", "表格"],
|
||||
icon: <Table className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: async () => {
|
||||
const documentId = currentDocumentId;
|
||||
try {
|
||||
const newTable = await createOnlineTable(documentId);
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "onlineTable",
|
||||
props: { tableId: newTable.id, title: newTable.title },
|
||||
content: [],
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } }));
|
||||
} catch (error) {
|
||||
console.error("Failed to create table:", error);
|
||||
// TODO: 插入错误提示块
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const createMindmapItem: DefaultReactSuggestionItem = {
|
||||
title: "思维导图",
|
||||
group: "高级",
|
||||
subtext: "插入可编辑导图(带顶栏工具)",
|
||||
aliases: ["mindmap", "swdt", "导图"],
|
||||
icon: <Spline className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "mindmap",
|
||||
props: { docId: currentDocumentId },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const createPageItem: DefaultReactSuggestionItem = {
|
||||
title: "嵌入页面",
|
||||
group: "嵌入",
|
||||
aliases: ["page", "ym", "子页面", "嵌入页面块"],
|
||||
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: async () => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const cursorBlock = cursor?.block as any;
|
||||
const firstText =
|
||||
Array.isArray(cursorBlock?.content) && cursorBlock.content.length > 0
|
||||
? (cursorBlock.content[0] as any)?.text
|
||||
: undefined;
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parentId: currentDocumentId,
|
||||
title: typeof firstText === "string" && firstText.trim() ? firstText : "未命名页面",
|
||||
blocks: cursorBlock ? [cursorBlock] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
const { pageId, title } = await response.json();
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "pageReference",
|
||||
props: { pageId, title, asChildPage: true },
|
||||
content: [],
|
||||
});
|
||||
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: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "heading",
|
||||
props: { level: preset.level },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const foldHeading: DefaultReactSuggestionItem = {
|
||||
title: "折叠标题",
|
||||
group: "标题",
|
||||
aliases: ["toggle", "zd", "fold"],
|
||||
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "heading",
|
||||
props: { level: 2, isToggleable: true },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const advancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "高级待办",
|
||||
group: "待办",
|
||||
subtext: "四态状态 · Alt 直接取消",
|
||||
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
|
||||
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const progressMeter: DefaultReactSuggestionItem = {
|
||||
title: "进度条",
|
||||
group: "进度",
|
||||
subtext: "自动读取下方待办完成度",
|
||||
aliases: ["jdt", "progress", "jindu"],
|
||||
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "progressMeter",
|
||||
props: { percent: 0, auto: true },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const foldAdvancedTodo: DefaultReactSuggestionItem = {
|
||||
title: "折叠高级待办",
|
||||
group: "待办",
|
||||
aliases: ["zdgjdb", "foldtodo"],
|
||||
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "advancedTodo",
|
||||
props: { status: "todo" },
|
||||
content: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const customItems = [
|
||||
...headingItems,
|
||||
foldHeading,
|
||||
createPageItem,
|
||||
createTableItem,
|
||||
createMindmapItem,
|
||||
advancedTodo,
|
||||
foldAdvancedTodo,
|
||||
progressMeter,
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
const insertMediaSelection = (selection: MediaSelection) => {
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
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",
|
||||
},
|
||||
content: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMediaPick = (mediaType: MediaKind) => {
|
||||
openPicker({
|
||||
mediaType,
|
||||
multiple: true,
|
||||
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,156 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildSidebarInitialData } from "@/lib/sidebar-data";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { usePageHeadTitle } from "./use-page-head-title";
|
||||
|
||||
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: "doc-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "标题 A",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-21T00:00:00.000Z",
|
||||
updated_at: "2026-04-21T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
|
||||
return buildSidebarInitialData({
|
||||
activeWorkspaceId: "ws-1",
|
||||
workspaces: [],
|
||||
documents,
|
||||
trashedDocuments: [],
|
||||
mindmaps: [],
|
||||
mediaAssets: [],
|
||||
trashedMediaAssets: [],
|
||||
tables: [],
|
||||
});
|
||||
}
|
||||
|
||||
function HookProbe(props: { documentId: string; fallbackTitle: string }) {
|
||||
const state = usePageHeadTitle(props);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="page-head-title"
|
||||
data-display-title={state.displayTitle}
|
||||
data-committed-title={state.committedTitle}
|
||||
data-has-draft={state.hasDraft ? "1" : "0"}
|
||||
/>
|
||||
<button type="button" data-testid="set-draft" onClick={() => state.setDraftTitle(" 新标题 ")}>
|
||||
设草稿
|
||||
</button>
|
||||
<button type="button" data-testid="commit-persisted" onClick={() => state.commitPersistedTitle("新标题")}>
|
||||
提交持久化
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("usePageHeadTitle", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("应优先使用 live sidebar snapshot 中的标题作为 committed title", async () => {
|
||||
const snapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "树标题",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={snapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("树标题");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("树标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("0");
|
||||
});
|
||||
|
||||
it("应只把本地输入保留为短暂 draft,并在 live 标题追平后自动清空", async () => {
|
||||
const initialSnapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "旧标题",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={initialSnapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const setDraftButton = container.querySelector<HTMLButtonElement>("[data-testid='set-draft']");
|
||||
act(() => {
|
||||
setDraftButton?.click();
|
||||
});
|
||||
|
||||
let probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe(" 新标题 ");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("旧标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("1");
|
||||
|
||||
const commitPersistedButton = container.querySelector<HTMLButtonElement>("[data-testid='commit-persisted']");
|
||||
act(() => {
|
||||
commitPersistedButton?.click();
|
||||
});
|
||||
|
||||
probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("1");
|
||||
|
||||
const syncedSnapshot = buildSidebarData([
|
||||
buildDocument({
|
||||
id: "doc-1",
|
||||
title: "新标题",
|
||||
updated_at: "2026-04-21T00:00:02.000Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PreferredSidebarSnapshotProvider data={syncedSnapshot}>
|
||||
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
|
||||
</PreferredSidebarSnapshotProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
probe = container.querySelector("[data-testid='page-head-title']");
|
||||
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-committed-title")).toBe("新标题");
|
||||
expect(probe?.getAttribute("data-has-draft")).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
|
||||
|
||||
function normalizePageHeadTitle(title: string | null | undefined): string {
|
||||
const normalized = String(title ?? "").trim();
|
||||
return normalized || "无标题";
|
||||
}
|
||||
|
||||
export function usePageHeadTitle(input: { documentId: string; fallbackTitle: string }) {
|
||||
const liveSidebarTitle = usePreferredSidebarDocumentTitle(input.documentId);
|
||||
const committedTitle = useMemo(
|
||||
() => normalizePageHeadTitle(liveSidebarTitle ?? input.fallbackTitle),
|
||||
[input.fallbackTitle, liveSidebarTitle],
|
||||
);
|
||||
const [draftState, setDraftState] = useState<{
|
||||
documentId: string;
|
||||
title: string | null;
|
||||
}>({
|
||||
documentId: input.documentId,
|
||||
title: null,
|
||||
});
|
||||
const draftTitle = draftState.documentId === input.documentId ? draftState.title : null;
|
||||
const hasDraft = draftTitle != null && normalizePageHeadTitle(draftTitle) !== committedTitle;
|
||||
|
||||
return {
|
||||
displayTitle: hasDraft ? draftTitle ?? committedTitle : committedTitle,
|
||||
committedTitle,
|
||||
hasDraft,
|
||||
setDraftTitle: (title: string) => {
|
||||
setDraftState({
|
||||
documentId: input.documentId,
|
||||
title,
|
||||
});
|
||||
},
|
||||
commitPersistedTitle: (title: string) => {
|
||||
setDraftState({
|
||||
documentId: input.documentId,
|
||||
title: normalizePageHeadTitle(title),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user