# 文本框宽度

> Fabric.js 文本自动换行遇到超长单词时会撑宽文本框。BoundedTextbox 保持宽度、在字母间断行，还能裁剪、显示省略号或缩小字号。

Source: https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/textbox-width
Last updated: 2026-10-02

Fabric.js Textbox 会在空格处换行，但比文本框还宽的单词永远不会被断开。这时 Fabric.js 文本自动换行会把整个文本框撑到这个单词的宽度（[fabric.js #2376](https://github.com/fabricjs/fabric.js/issues/2376)）。长链接、邮箱地址，或者没有空格的中文、日文句子，都会让文本框超出你的版面。`BoundedTextbox` 会保持你设定的宽度。

## 试一试

```tsx
"use client";

import { Textbox } from "fabric";
import {
  BoundedTextbox,
  type TextOverflow,
} from "fabricjs-document-engine/text";
import { ScissorsIcon, ShrinkIcon, TextIcon } 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 TEXT =
  "Read https://fabricjs-document-engine.jscrate.dev/docs/guides/textbox-width before you place long links in a design.";

const OVERFLOWS: TextOverflow[] = ["visible", "clip", "ellipsis"];

export default function BoundedTextboxDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const plainRef = useRef<Textbox | null>(null);
  const boundedRef = useRef<BoundedTextbox | null>(null);
  const [width, setWidth] = useState(180);
  const [overflow, setOverflow] = useState<TextOverflow>("ellipsis");
  const [shrink, setShrink] = useState(false);
  const [sizes, setSizes] = useState({ plain: 0, bounded: 0, font: 18 });

  useEffect(() => {
    if (!canvas) return;
    const common = {
      top: 40,
      fontSize: 18,
      originX: "left",
      originY: "top",
    } as const;
    const plain = new Textbox(TEXT, {
      ...common,
      left: 16,
      width: 180,
      fill: "#71717a",
    });
    const bounded = new BoundedTextbox(TEXT, {
      ...common,
      left: 256,
      width: 180,
      maxHeight: 110,
      overflow: "ellipsis",
      fill: "#0d0d0d",
    });
    plainRef.current = plain;
    boundedRef.current = bounded;
    canvas.add(plain, bounded);
    canvas.requestRenderAll();
    return () => {
      canvas.remove(plain, bounded);
    };
  }, [canvas]);

  useEffect(() => {
    const plain = plainRef.current;
    const bounded = boundedRef.current;
    if (!canvas || !plain || !bounded) return;
    plain.set({ width });
    bounded.set({
      width,
      overflow,
      fit: shrink ? "shrink" : "none",
      minFontSize: 8,
    });
    canvas.requestRenderAll();
    setSizes({
      plain: Math.round(plain.width),
      bounded: Math.round(bounded.width),
      font: Math.round(bounded.fontSize * 2) / 2,
    });
  }, [canvas, width, overflow, shrink]);

  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      <label className="flex items-center gap-3 text-[13px]">
        <span className="shrink-0">{t("textbox.width", { width })}</span>
        <input
          type="range"
          min={60}
          max={210}
          value={width}
          onChange={(event) => setWidth(Number(event.target.value))}
          className="w-full accent-[#6f00ff]"
        />
      </label>

      <Toolbar>
        {OVERFLOWS.map((value) => (
          <ToolButton
            key={value}
            icon={value === "clip" ? ScissorsIcon : TextIcon}
            label={t(`textbox.${value}`)}
            primary={overflow === value}
            onClick={() => setOverflow(value)}
          />
        ))}
        <ToolButton
          icon={ShrinkIcon}
          label={t("textbox.shrink")}
          primary={shrink}
          onClick={() => setShrink((value) => !value)}
        />
      </Toolbar>

      <Status tone="ok">
        {t("textbox.status", {
          plain: sizes.plain,
          bounded: sizes.bounded,
          font: sizes.font,
        })}
      </Status>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

灰色的是普通的 Fabric.js Textbox，黑色的是文字和宽度都相同的 `BoundedTextbox`。拖动滑块，比较它们的宽度。

## 保持宽度

```ts
import { createDocumentEngine } from "fabricjs-document-engine";
import { BoundedTextbox, textObjects } from "fabricjs-document-engine/text";

const engine = createDocumentEngine({ canvas, customObjects: textObjects });

canvas.add(
  new BoundedTextbox("A long link: https://example.com/a/very/long/path", {
    width: 240,
    fontSize: 20,
  })
);
```

- 比文本框还宽的单词会单独占一行，并在字母之间断开，和 CSS `overflow-wrap: anywhere` 的 canvas文字换行方式相同。
- 断行发生在字素之间，所以 emoji 和带重音的字母不会被拆开。
- 样式、光标和选区在这些断行处照常工作。
- 文本框不会比最宽的那个字母更窄。
- `breakWords: "never"` 会关闭这种断行，让它和 Textbox 的 Fabric.js 文本自动换行一样。

用 `customObjects: textObjects` 注册这个类，含有 `BoundedTextbox` 的文档才能保存和加载。没有引擎时，调用一次 `registerTextObjects()`。

## 限制高度

设置 `maxHeight`，再选择超出部分的处理方式：

| `overflow` | 看到的效果                                             |
| ---------- | ------------------------------------------------------ |
| `visible`  | 显示所有行，和 Textbox 一样（默认）。                  |
| `clip`     | 文字在 `maxHeight` 处被裁掉。                          |
| `ellipsis` | 超过 `maxHeight` 的行被隐藏，最后一行以省略号“…”结尾。 |

```ts
new BoundedTextbox(longText, {
  width: 240,
  maxHeight: 120,
  overflow: "ellipsis",
});
```

文字本身不会改变：复制、搜索和编辑仍然能看到每一个字。用户编辑时，文本框只裁剪、不画省略号，所以光标不会被盖住。SVG 和 PDF 导出使用同样的裁剪。

## 缩小字号直到放得下

`fit: "shrink"` 会降低字号，直到文字放进 `maxHeight`。它用二分查找按半磅尝试字号，所以 48px 的标题大约排版七次就够了，而不是四十次。

```ts
const title = new BoundedTextbox(headline, {
  width: 300,
  maxHeight: 90,
  fit: "shrink",
  minFontSize: 10,
  fontSize: 48,
});

console.log(title.fontSize, title.getBaseFontSize());
```

`fontSize` 读到的是实际绘制的字号，例如 31.5；`getBaseFontSize()` 读到的是你设定的字号 48。文档保存的是你设定的字号，而不是缩小后的字号。文字变短后，字号会长回这个大小。自带 `fontSize` 的字母按相同比例缩小。

## 字母相连的文字

阿拉伯文或带连字的字体，请加上 `shaping: true`，让断行和光标跟随相连的字母。见[阿拉伯文和从右到左文字](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/rtl-text)。

所有配置项都列在[文字 API](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/text-api)里。
