Fabric.js Document Engine

搜索文档

查找页面或章节

EN

复制、粘贴和图层

查看 Markdown

剪贴板和图层命令,id、位置和撤销历史都正确。

做 Fabric.js 复制粘贴,通常是对对象调用 clone。这样复制对象会保留原来的 id,从移动或旋转过的选区里复制的对象可能落在错误的位置,每一步还要自己处理撤销。引擎的剪贴板和图层命令把这三点都处理好了。

正在加载在线示例,代码在下方。

clipboard-layers-demo.tsx
"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,所以重新打开后图层名称不会丢。所有命令列在辅助函数里,撤销和重做说明了步骤是怎样记录的。