Fabric.js measures each letter on its own, then adds the widths. But it draws a run of letters in one go, and the font joins them: Arabic letters take their connected forms, and "ffi" can become one ligature. In Fabric.js RTL text the drawn line is then shorter than the measured one, so the cursor position, the selection and clicks drift away from the letters (fabric.js #4815). In Arabic text at 40px the cursor can land two letters away.
Try it
Loading the live demo. Its code is below.
"use client";
import { IText } from "fabric";
import { ShapedIText } from "fabricjs-document-engine/text";
import { TextCursorInputIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { DemoCanvas, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
const ARABIC = "مرحبا بالعالم العربي الجميل";
const FIRST_SPACE = [...ARABIC].indexOf(" ");
export default function ShapedTextDemo() {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const textsRef = useRef<{ plain: IText; shaped: IText } | null>(null);
const [active, setActive] = useState<"plain" | "shaped" | null>(null);
useEffect(() => {
if (!canvas) return;
const common = {
fontSize: 34,
fontFamily: "sans-serif",
direction: "rtl",
textAlign: "right",
originX: "right",
originY: "top",
left: PAGE.width - 24,
cursorColor: "#e11d48",
cursorWidth: 3,
} as const;
const plain = new IText(ARABIC, { ...common, top: 50, fill: "#71717a" });
const shaped = new ShapedIText(ARABIC, {
...common,
top: 170,
fill: "#0d0d0d",
});
textsRef.current = { plain, shaped };
canvas.add(plain, shaped);
canvas.requestRenderAll();
return () => {
canvas.remove(plain, shaped);
};
}, [canvas]);
function placeCursor(which: "plain" | "shaped") {
const texts = textsRef.current;
if (!canvas || !texts) return;
texts.plain.exitEditing();
texts.shaped.exitEditing();
const text = texts[which];
canvas.setActiveObject(text);
text.enterEditing();
text.setSelectionStart(FIRST_SPACE);
text.setSelectionEnd(FIRST_SPACE);
canvas.requestRenderAll();
setActive(which);
}
return (
<DemoFrame>
<DemoCanvas elementRef={elementRef} label={t("canvas")} />
<Toolbar>
<ToolButton
icon={TextCursorInputIcon}
label={t("shaped.plain")}
primary={active === "plain"}
disabled={!canvas}
onClick={() => placeCursor("plain")}
/>
<ToolButton
icon={TextCursorInputIcon}
label={t("shaped.shaped")}
primary={active === "shaped"}
disabled={!canvas}
onClick={() => placeCursor("shaped")}
/>
</Toolbar>
<Status tone={active === "plain" ? "error" : active ? "ok" : "idle"}>
{active ? t(`shaped.${active}Result`) : t("shaped.hint")}
</Status>
</DemoFrame>
);
}Both lines hold the same Arabic text. The buttons put the cursor right after the first word.
Use the shaped classes
import {
ShapedIText,
ShapedTextbox,
textObjects,
} from "fabricjs-document-engine/text";
const engine = createDocumentEngine({ canvas, customObjects: textObjects });
canvas.add(
new ShapedIText("مرحبا بالعالم", {
direction: "rtl",
textAlign: "right",
fontSize: 36,
})
);ShapedIText and ShapedTextbox measure each position from the text as the browser shapes it, word by word, and keep the results in a cache. Typing, selection, clicks, copy and paste work as in IText and Textbox.
- Fabric.js RTL text uses
direction: "rtl"as before. - Ligatures in Latin fonts, such as "fi" and "ffi", get the same fix.
- Text with letter spacing (
charSpacing),justify, or on a path is drawn letter by letter by Fabric, so it is already measured right and left as it is.
In a BoundedTextbox
new BoundedTextbox(arabicText, { width: 260, direction: "rtl", shaping: true });shaping: true also makes line breaks follow the joined letters. See Textbox width.
The measuring functions are in the Text API, for your own Fabric.js RTL text classes.