Fabric.js Document Engine

搜索文档

查找页面或章节

EN

用代码修改文字样式

查看 Markdown

用代码编辑 fabricjs 富文本,同时保留每个字母的样式。

Fabric.js 按位置保存每个字母的样式:第 0 行第 6 个字母是粗体。代码修改 text 时,这些位置不会移动,所以插入文字之后,原来的粗体单词就不再是粗体了(fabric.js #6133)。setSelectionStyles 可以修改选中文字的样式,但文字本身改变时它帮不上忙。insertChars 和 removeChars 会移动样式,但它们不触发事件,用户输入时还会让隐藏的 textarea 落后。

试一试

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

text-commands-demo.tsx
"use client";
 
import { IText } from "fabric";
import { createTextCommands } from "fabricjs-document-engine";
import { useDocumentEngine } from "fabricjs-document-engine/react";
import {
  BoldIcon,
  RotateCcwIcon,
  TextCursorIcon,
  Undo2Icon,
  WandIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
 
import { DemoCanvas, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
 
const STRONG = { fontWeight: "bold", fill: "#6f00ff" } as const;
 
function makeText() {
  return new IText("Hello world", {
    left: 40,
    top: 110,
    originX: "left",
    originY: "top",
    fontSize: 46,
    fill: "#0d0d0d",
    styles: { 0: { 6: STRONG, 7: STRONG, 8: STRONG, 9: STRONG, 10: STRONG } },
  });
}
 
export default function TextCommandsDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const textRef = useRef<IText | null>(null);
  const [message, setMessage] = useState("");
 
  function reset() {
    if (!engine) return;
    if (textRef.current) engine.canvas.remove(textRef.current);
    const text = makeText();
    textRef.current = text;
    engine.canvas.add(text);
    engine.clearHistory();
    engine.canvas.requestRenderAll();
    setMessage("");
  }
 
  useEffect(reset, [engine]);
 
  function current() {
    return engine?.getObjectById(
      (textRef.current as unknown as { id: string } | null)?.id ?? ""
    ) as IText | undefined;
  }
 
  function insertWithCommands() {
    const text = current();
    if (!engine || !text) return;
    createTextCommands(engine).insertText(text, 0, "Oh, ");
    setMessage(t("textCommands.inserted"));
  }
 
  function setDirectly() {
    const text = current();
    if (!engine || !text) return;
    text.set("text", `Oh, ${text.text}`);
    engine.canvas.requestRenderAll();
    setMessage(t("textCommands.direct"));
  }
 
  function boldFirstWord() {
    const text = current();
    if (!engine || !text) return;
    const end = [...text.text].indexOf(" ");
    createTextCommands(engine).setTextStyle(
      text,
      0,
      end === -1 ? text.text.length : end,
      { fontWeight: "bold" }
    );
    setMessage(t("textCommands.styled"));
  }
 
  async function undo() {
    if (!engine) return;
    await engine.undo();
    textRef.current = engine.canvas.getObjects()[0] as IText;
    setMessage(t("textCommands.undone"));
  }
 
  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />
 
      <Toolbar>
        <ToolButton
          icon={WandIcon}
          label={t("textCommands.insert")}
          primary
          disabled={!engine}
          onClick={insertWithCommands}
        />
        <ToolButton
          icon={TextCursorIcon}
          label={t("textCommands.setText")}
          disabled={!engine}
          onClick={setDirectly}
        />
        <ToolButton
          icon={BoldIcon}
          label={t("textCommands.bold")}
          disabled={!engine}
          onClick={boldFirstWord}
        />
        <ToolButton
          icon={Undo2Icon}
          label={t("undo")}
          iconOnly
          disabled={!engine}
          onClick={() => void undo()}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("textCommands.reset")}
          iconOnly
          disabled={!engine}
          onClick={reset}
        />
      </Toolbar>
 
      <Status tone={message ? "ok" : "idle"}>
        {message || t("textCommands.hint")}
      </Status>
    </DemoFrame>
  );
}

直接设置 text 演示了这个问题:粗体和紫色停留在旧的字母上。用命令插入 会让样式一直留在 “world” 上。

修改文字而不丢失样式

import { createTextCommands } from "fabricjs-document-engine";
 
const text = createTextCommands(engine);
 
text.insertText(title, 0, "New: ");
text.replaceText(title, 5, 9, "Big", { fontWeight: "bold" });
text.deleteText(title, 0, 5);
text.setTextStyle(title, 0, 3, { fill: "#6f00ff" });
  • 位置按字素计算,和光标一样,所以一个 emoji 算一个字母。
  • 插入的文字沿用前一个字母的字体样式,或者使用你传入的 style。传入 null 则不带样式。
  • 光标、选区和隐藏的 textarea 都会跟着修改移动,即使用户正在输入。
  • 会触发 changed 和 text:changed,文档会被标记为未保存。
  • 每个命令都是一步撤销。

setTextStyle 和 setSelectionStyles 一样把样式合并进一个范围,并替你记录撤销步骤。这些都适用于 IText、Textbox 及其子类中的 fabricjs 富文本。

多次修改合成一步

命令背后的函数 replaceTextRange 和 setTextRangeStyle 本身不记录任何步骤。把它们放进事务中:

import { replaceTextRange } from "fabricjs-document-engine";
 
engine.transaction("Rename", () => {
  replaceTextRange(title, 0, 5, "Hello");
  replaceTextRange(subtitle, 0, 0, "Draft: ");
});

样式以 { start, end, style } 的区间移动,所以一次修改的开销取决于区间数量,而不是字母数量。shiftStyleRuns 也已导出,可以用在你自己的 fabricjs 富文本工具里。

手机上的输入见手机上的文字输入,撤销分组见撤销和重做。所有签名都在文字 API里,fabricjs 富文本的其他辅助函数也在那里。