0.3.2.1 UI修复3
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user