0.3.2.1 UI修复3

This commit is contained in:
liaibo
2026-01-20 07:24:12 +08:00
parent fa7235b1a7
commit f13de35321
16 changed files with 741 additions and 108 deletions
@@ -783,6 +783,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
)}
floatingOptions={{ placement: "left" }}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
@@ -229,9 +229,50 @@ const MediaBlockContent = ({ block, editor }: any) => {
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
};
const viewOriginal = () => {
if (!fileUrl) return;
window.open(fileUrl, "_blank", "noopener,noreferrer");
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 () => {
@@ -241,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
return;
}
try {
const assetId = (block.props as { assetId?: string })?.assetId;
const assetId = await resolveAssetId();
const res = assetId
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
: await fetch(
@@ -258,16 +299,20 @@ const MediaBlockContent = ({ block, editor }: any) => {
target.searchParams.set("fileUrl", signedUrl);
target.searchParams.set("fileName", displayFileName);
target.searchParams.set("fileType", extension || "docx");
if (assetId) {
target.searchParams.set("assetId", assetId);
}
window.open(target.toString(), "_blank", "noopener,noreferrer");
} catch (error) {
window.alert((error as Error).message);
}
};
const downloadAsset = () => {
if (!fileUrl) return;
const downloadAsset = async () => {
const url = await resolveLatestFileUrl();
if (!url) return;
const anchor = document.createElement("a");
anchor.href = fileUrl;
anchor.href = url;
anchor.download = block.props.fileName || block.props.caption || typeLabel;
anchor.click();
};
@@ -364,44 +409,111 @@ const MediaBlockContent = ({ block, editor }: any) => {
return "text-[#9B9A97]";
};
return (
<div
role="button"
tabIndex={0}
onClick={() => {
if (isOfficeDoc) {
void openWithOnlyOffice();
} else {
downloadAsset();
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
return (
<div
role="button"
tabIndex={0}
onClick={() => {
if (isOfficeDoc) {
void openWithOnlyOffice();
} else {
downloadAsset();
void downloadAsset();
}
}
}}
className="group flex items-center gap-2 px-3 py-2 rounded-[4px] bg-white hover:bg-[#F5F5F5] transition-colors duration-200 cursor-pointer select-none"
>
<span className={cn("w-5 h-5 shrink-0 flex items-center justify-center", getIconColor())}>
<Paperclip className="h-4 w-4" />
</span>
<div className="flex flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-[14px] text-[#37352F] font-normal truncate">{displayFileName}</p>
{block.props.fileSize ? (
<p className="text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</p>
) : null}
}}
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="下载"
title="下载"
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
onClick={() => {
void downloadAsset();
}}
>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDeleteAsset}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
};
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
};
const figure = (
<figure
@@ -462,7 +574,9 @@ const MediaBlockContent = ({ block, editor }: any) => {
key: "download",
label: `下载${typeLabel}`,
icon: <Download className="h-4 w-4" />,
onClick: downloadAsset,
onClick: () => {
void downloadAsset();
},
},
{
key: "delete",
@@ -475,17 +589,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
return (
<div className="wolai-media" ref={mediaRef}>
<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") {
viewOriginal();
}
}}
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">
@@ -494,6 +608,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
) : (
figure
)}
{assetType !== "file" && (
<div className="wolai-media__quickbar">
{quickActions.map((action) => (
<button
@@ -532,13 +647,25 @@ const MediaBlockContent = ({ block, editor }: any) => {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem onClick={viewOriginal}></DropdownMenuItem>
{assetType === "file" && isOfficeDoc && (
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使 ONLYOFFICE </DropdownMenuItem>
)}
<DropdownMenuItem onClick={downloadAsset}></DropdownMenuItem>
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
void viewOriginal();
}}
>
</DropdownMenuItem>
{assetType === "file" && isOfficeDoc && (
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使 ONLYOFFICE </DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
void downloadAsset();
}}
>
</DropdownMenuItem>
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
@@ -550,6 +677,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{canResize && (
<>
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
@@ -12,6 +12,7 @@ import {
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";
@@ -36,6 +37,15 @@ type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
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;
@@ -410,15 +420,133 @@ type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
workspaceId: string | null;
};
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const Components = useComponentsContext()!;
const { editor, block, blockDragStart, blockDragEnd, freezeMenu, unfreezeMenu, currentDocumentId, workspaceId } = props;
const [activeLine, setActiveLine] = useState<null | "top" | "bottom">(null);
const [menuOpen, setMenuOpen] = useState(false);
const [hovering, setHovering] = useState(false);
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 lineOffsetPx = 6;
const lineGapPx = 5;
const setFrozen = useCallback(
(next: boolean) => {
if (menuFrozenRef.current === next) return;
menuFrozenRef.current = next;
if (next) {
freezeMenu();
} else {
unfreezeMenu();
}
},
[freezeMenu, unfreezeMenu],
);
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();
};
return (
<Components.Generic.Menu.Root
onOpenChange={(open: boolean) => {
setMenuOpen(open);
setFrozen(open || hovering);
}}
position={"left"}
>
<div
className="relative w-7 overflow-visible"
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
onMouseEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onMouseLeave={() => {
setHovering(false);
setActiveLine(null);
setFrozen(menuOpen || false);
}}
>
{activeLine !== "bottom" && (
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("top")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{activeLine === "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 -translate-x-1/2 -translate-y-1/2"
style={{ top: hoverPadPx + rowHeightPx / 2 }}
onMouseEnter={() => setActiveLine(null)}
>
<Components.Generic.Menu.Trigger>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
className={"bn-button bn-drag-handle"}
icon={<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />}
/>
</Components.Generic.Menu.Trigger>
</div>
{activeLine !== "top" && (
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ bottom: hoverPadPx - lineOffsetPx - lineGapPx }}
onMouseEnter={() => setActiveLine("bottom")}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{activeLine === "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}
dragHandleMenu={(dragProps) => (
<CustomDragHandleMenu
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
currentDocumentId={props.currentDocumentId}
workspaceId={props.workspaceId}
/>
)}
/>
<SideMenu {...props}>
<WolaiDragHandleWithInsert {...props} />
</SideMenu>
);