Fabric.js Document Engine

Search documentation

Find a page or section

中文

Text styles from code

View as Markdown

Text edits from code that keep each letter's style, beyond Fabric.js setSelectionStyles.

Fabric.js keeps the style of each letter by its position: line 0, letter 6 is bold. When code changes text, those positions do not move, so after an insert the bold word is no longer the bold word (fabric.js #6133). Fabric.js setSelectionStyles changes the style of a range, but it does not help when the text itself changes. insertChars and removeChars do move styles, but they fire no event, and while the user types they leave the hidden textarea behind.

Try it

Loading the live demo. Its code is below.

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>
  );
}

Set text directly shows the problem: the bold, purple text color stays on the old letters. Insert with commands keeps it on "world".

Edit text without losing styles

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" });
  • Positions count graphemes, as the cursor does, so an emoji is one letter.
  • Inserted text takes the style of the letter before it, or the style you pass. null inserts it without style.
  • The cursor, the selection and the hidden textarea follow the edit, even while the user is typing.
  • changed and text:changed fire, so the document is marked unsaved.
  • Each command is one undo step.

setTextStyle merges a style into a range like Fabric.js setSelectionStyles does, and records the undo step for you. All of this works for rich text in IText, Textbox and their subclasses.

Several edits as one step

The functions behind the commands, replaceTextRange and setTextRangeStyle, record nothing on their own. Wrap them in a transaction:

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

Styles are moved as runs of { start, end, style }, so an edit costs the number of runs, not the number of letters. shiftStyleRuns is exported for your own text tools.

For typing on phones see Typing on phones, and for undo grouping Undo and redo. Every signature is in the Text API, next to Fabric.js setSelectionStyles helpers you may still use.