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.
"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
styleyou pass.nullinserts it without style. - The cursor, the selection and the hidden textarea follow the edit, even while the user is typing.
changedandtext:changedfire, 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.