Fabric.js copy paste usually means calling clone on an object. A clone object keeps the original's id, objects copied from a moved or rotated selection can land in the wrong place, and every step needs its own undo handling. The engine's clipboard and layer commands handle all three.
Loading the live demo. Its code is below.
"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>
);
}Copy, cut and paste
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 });- Every pasted object, group child and clip path gets a new id, so a duplicate object never clashes with the original.
- Objects copied from a moved, rotated or scaled selection keep the position they have on the canvas.
- Custom properties of registered classes are kept.
- Each paste moves
offsetfurther, so repeated pastes do not stack on top of each other.
Copy between tabs
The Fabric.js copy paste content is plain JSON, so it can travel through the system clipboard:
await navigator.clipboard.writeText(JSON.stringify(clipboard.read()));
clipboard.write(JSON.parse(await navigator.clipboard.readText()));write checks the JSON like a loaded document, so pasted content cannot bring in unsafe image addresses. An object type the engine has not registered rejects with UNKNOWN_OBJECT_TYPE.
Bring to front and other layer commands
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]);- Each command moves the objects you pass, or the selection, and records one undo step.
- Several selected objects keep their order, and neighbours move as a block.
- A pinned object, such as a background or a frame, never moves, and other objects never pass it.
- A command that would change nothing returns
falseand records nothing.
A layers panel
getLayers(engine) lists the objects from top to bottom as { id, type, name, index, visible, locked }. In React, useLayers(engine) returns the same list and updates after every change:
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>);
}The engine saves each object's name, so layers keep their labels after reopening. Every command is listed with the helpers, and undo and redo explains how steps are recorded.