做 Fabric.js 复制粘贴,通常是对对象调用 clone。这样复制对象会保留原来的 id,从移动或旋转过的选区里复制的对象可能落在错误的位置,每一步还要自己处理撤销。引擎的剪贴板和图层命令把这三点都处理好了。
正在加载在线示例,代码在下方。
"use client";
import { type Canvas, type FabricObject, Rect } from "fabric";
import {
bringForward,
bringToFront,
createClipboard,
type LayerOptions,
sendBackward,
sendToBack,
} from "fabricjs-document-engine";
import { useDocumentEngine, useLayers } from "fabricjs-document-engine/react";
import {
ArrowDownIcon,
ArrowDownToLineIcon,
ArrowUpIcon,
ArrowUpToLineIcon,
ClipboardPasteIcon,
CopyIcon,
ScissorsIcon,
Undo2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
DemoCanvas,
DemoFrame,
seedShapes,
Status,
Toolbar,
ToolButton,
} from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
// The background stays at the bottom, whatever the layer buttons do.
const KEEP_BACKGROUND: LayerOptions = {
pinned: (object) =>
(object as FabricObject & { name?: string }).name === "background",
};
const NAMES = ["card", "sun", "pill", "triangle"];
export default function ClipboardLayersDemo() {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas);
const clipboard = useMemo(
() => (engine ? createClipboard(engine) : null),
[engine]
);
const layers = useLayers(engine);
const [message, setMessage] = useState("");
useEffect(() => {
if (!engine) return;
const background = new Rect({
originX: "left",
originY: "top",
left: 0,
top: 0,
width: PAGE.width,
height: PAGE.height,
fill: "#f4f4f5",
selectable: false,
evented: false,
});
const shapes = seedShapes();
[background, ...shapes].forEach((object, index) =>
object.set("name", index === 0 ? "background" : NAMES[index - 1])
);
engine.canvas.add(background, ...shapes);
engine.clearHistory();
}, [engine]);
function selected(): FabricObject[] {
return (engine?.canvas as Canvas | undefined)?.getActiveObjects() ?? [];
}
function run(label: string, work: () => boolean | number | Promise<unknown>) {
if (!engine) return;
if (selected().length === 0 && label !== "paste" && label !== "undo") {
setMessage(t("layers.selectFirst"));
return;
}
void Promise.resolve(work()).then(() =>
setMessage(t(`layers.done.${label}`))
);
}
function select(id: string | undefined) {
const object = id ? engine?.getObjectById(id) : undefined;
const interactive = engine?.canvas as Canvas | undefined;
if (!object || !interactive || object.selectable === false) return;
interactive.setActiveObject(object);
interactive.requestRenderAll();
}
return (
<DemoFrame>
<DemoCanvas elementRef={elementRef} label={t("canvas")} />
<Toolbar>
<ToolButton
icon={CopyIcon}
label={t("layers.copy")}
iconOnly
disabled={!engine}
onClick={() => run("copy", () => clipboard!.copy())}
/>
<ToolButton
icon={ScissorsIcon}
label={t("layers.cut")}
iconOnly
disabled={!engine}
onClick={() => run("cut", () => clipboard!.cut())}
/>
<ToolButton
icon={ClipboardPasteIcon}
label={t("layers.paste")}
iconOnly
disabled={!engine}
onClick={() => run("paste", () => clipboard!.paste())}
/>
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
<ToolButton
icon={ArrowUpToLineIcon}
label={t("layers.front")}
iconOnly
disabled={!engine}
onClick={() =>
run("front", () =>
bringToFront(engine!, undefined, KEEP_BACKGROUND)
)
}
/>
<ToolButton
icon={ArrowUpIcon}
label={t("layers.forward")}
iconOnly
disabled={!engine}
onClick={() =>
run("forward", () =>
bringForward(engine!, undefined, KEEP_BACKGROUND)
)
}
/>
<ToolButton
icon={ArrowDownIcon}
label={t("layers.backward")}
iconOnly
disabled={!engine}
onClick={() =>
run("backward", () =>
sendBackward(engine!, undefined, KEEP_BACKGROUND)
)
}
/>
<ToolButton
icon={ArrowDownToLineIcon}
label={t("layers.back")}
iconOnly
disabled={!engine}
onClick={() =>
run("back", () => sendToBack(engine!, undefined, KEEP_BACKGROUND))
}
/>
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
<ToolButton
icon={Undo2Icon}
label={t("undo")}
iconOnly
disabled={!engine}
onClick={() => run("undo", () => engine!.undo())}
/>
</Toolbar>
<ol
aria-label={t("layers.list")}
className="flex flex-wrap gap-1.5 text-[12px]"
>
{layers.map((layer) => (
<li key={layer.id}>
<button
type="button"
disabled={layer.locked}
onClick={() => select(layer.id)}
className={cn(
"rounded-md border px-2 py-0.5 transition-colors hover:bg-muted disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
)}
>
{layer.name ?? layer.type}
{layer.locked ? ` · ${t("layers.pinned")}` : ""}
</button>
</li>
))}
</ol>
<Status tone={message ? "ok" : "idle"}>
{message || t("layers.hint")}
</Status>
</DemoFrame>
);
}复制、剪切和粘贴
import { createClipboard } from "fabricjs-document-engine";
const clipboard = createClipboard(engine, { offset: 10 });
clipboard.copy(); // the selection, or pass objects
await clipboard.paste(); // one undo step
clipboard.cut(); // one undo step; the next paste lands in place
await clipboard.paste({ target: otherEngine });- 每个粘贴出来的对象、编组子对象和裁剪路径都会得到新 id,副本不会和原对象冲突。
- 从移动、旋转或缩放过的选区里复制的对象,保持它们在画布上的位置。
- 已注册类的自定义属性会被保留。
- 每次粘贴都会多偏移
offset,多次粘贴不会叠在一起。
在标签页之间复制
Fabric.js 复制粘贴的内容是普通 JSON,可以通过系统剪贴板传递,这和通常的 canvas复制粘贴 不同:
await navigator.clipboard.writeText(JSON.stringify(clipboard.read()));
clipboard.write(JSON.parse(await navigator.clipboard.readText()));write 会像检查加载的文档一样检查 JSON,所以粘贴的内容不能带入不安全的图片地址。引擎没有注册的对象类型会以 UNKNOWN_OBJECT_TYPE 拒绝。
置顶和其他图层命令
import { bringForward, bringToFront, sendBackward, sendToBack } from "fabricjs-document-engine";
const keepBackground = { pinned: (object) => object.name === "background" };
bringToFront(engine);
sendToBack(engine, undefined, keepBackground);
bringForward(engine, [logo]);- 每个命令移动你传入的对象或当前选区,并记录一步撤销。
- 选中的多个对象保持原有顺序,相邻的对象作为一组移动。
- 固定的对象(例如背景或边框)永远不会移动,其他对象也不会越过它。
- 不会改变任何顺序的命令返回
false,也不记录步骤。
图层面板
getLayers(engine) 从上到下列出 canvas图层,每项是 { id, type, name, index, visible, locked }。在 React 中,useLayers(engine) 返回同样的列表,并在每次改动后更新:
import { useLayers } from "fabricjs-document-engine/react";
function LayersPanel({ engine }) {
const layers = useLayers(engine);
return layers.map((layer) => <div key={layer.id}>{layer.name ?? layer.type}</div>);
}引擎会保存每个对象的 name,所以重新打开后图层名称不会丢。所有命令列在辅助函数里,撤销和重做说明了步骤是怎样记录的。