# 手机上的文字输入

> Fabric.js 移动端文字输入：Android 键盘、自动更正、联想和输入法都能把文字和样式放在正确位置，iOS 也不会缩放页面。

Source: https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/mobile-text-input
Last updated: 2026-10-02

Fabric.js 通过一个隐藏的 textarea 接收输入。在手机上它工作得并不好（[fabric.js #6588](https://github.com/fabricjs/fabric.js/issues/6588)），所以 Fabric.js 移动端的文字编辑需要额外处理：

- Android 键盘的每个按键都发送键码 229，Fabric 无法知道按了哪个键。
- 键盘会修改光标之外的文字：自动更正会替换光标前的单词，联想会替换正在输入的单词。
- 在空格键上滑动会移动光标，却没有任何按键事件。
- textarea 的字号只有 1px，获得焦点时 iOS 会缩放页面。

Fabric 按它自己记录的光标推算每次修改，所以自动更正之后，IText 或 Textbox 里的字母或样式会落到错误的位置。

## 试一试

```tsx
"use client";

import { type Canvas, IText } from "fabric";
import {
  attachMobileTextInput,
  type MobileTextInput,
} from "fabricjs-document-engine/text";
import { RotateCcwIcon, SmartphoneIcon, SpellCheckIcon } 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 BOLD = { fontWeight: "bold", fill: "#6f00ff" } as const;

function makeText() {
  return new IText("im here", {
    left: 40,
    top: 110,
    originX: "left",
    originY: "top",
    fontSize: 44,
    fill: "#0d0d0d",
    styles: { 0: { 3: BOLD, 4: BOLD, 5: BOLD, 6: BOLD } },
  });
}

function boldLetters(text: IText) {
  return [...text.text]
    .filter((_, index) => text.styles[0]?.[index]?.fontWeight === "bold")
    .join("");
}

export default function MobileInputDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const adapterRef = useRef<MobileTextInput | null>(null);
  const textRef = useRef<IText | null>(null);
  const [adapter, setAdapter] = useState(true);
  const [result, setResult] = useState<{ text: string; bold: string } | null>(
    null
  );

  function reset(target: Canvas) {
    if (textRef.current) target.remove(textRef.current);
    const text = makeText();
    textRef.current = text;
    target.add(text);
    target.requestRenderAll();
    setResult(null);
  }

  useEffect(() => {
    if (!canvas) return;
    if (adapter) adapterRef.current = attachMobileTextInput(canvas);
    reset(canvas);
    return () => {
      adapterRef.current?.detach();
      adapterRef.current = null;
    };
  }, [canvas, adapter]);

  function autocorrect() {
    const text = textRef.current;
    if (!canvas || !text) return;
    canvas.setActiveObject(text);
    text.enterEditing();
    text.setSelectionStart(text.text.length);
    text.setSelectionEnd(text.text.length);
    const textarea = text.hiddenTextarea;
    if (!textarea) return;
    textarea.dispatchEvent(
      new KeyboardEvent("keydown", {
        keyCode: 229,
        key: "Unidentified",
      } as KeyboardEventInit)
    );
    textarea.value = "I'm here";
    textarea.setSelectionRange(8, 8);
    textarea.dispatchEvent(
      new InputEvent("input", {
        inputType: "insertReplacementText",
        bubbles: true,
      })
    );
    text.exitEditing();
    canvas.requestRenderAll();
    setResult({ text: text.text, bold: boldLetters(text) });
  }

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

      <Toolbar>
        <ToolButton
          icon={SmartphoneIcon}
          label={adapter ? t("mobile.on") : t("mobile.off")}
          primary={adapter}
          onClick={() => setAdapter((value) => !value)}
        />
        <ToolButton
          icon={SpellCheckIcon}
          label={t("mobile.autocorrect")}
          disabled={!canvas}
          onClick={autocorrect}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("mobile.reset")}
          disabled={!canvas}
          onClick={() => canvas && reset(canvas)}
        />
      </Toolbar>

      <Status tone={!result ? "idle" : result.bold === "here" ? "ok" : "error"}>
        {result
          ? t("mobile.result", { text: result.text, bold: result.bold || "—" })
          : t("mobile.hint")}
      </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>
  );
}
```

按钮会模拟 Android 键盘把 “im” 改成 “I'm” 的过程：修改光标之前的文字。关闭适配器时，粗体样式会移到错误的字母上。

## 开启适配器

```ts
import { attachMobileTextInput } from "fabricjs-document-engine/text";

const mobileInput = attachMobileTextInput(canvas);

mobileInput.detach();
```

在用户开始编辑之前调用一次。它对画布上所有的 IText、Textbox 及其子类都有效，包括之后添加的对象。

- 每次修改都从文字本身读取：新旧文字相同的开头和结尾之间，就是真正改变的部分。样式跟着字母移动。
- 光标跟随 textarea，包括 Android 键盘自己做出的移动。
- textarea 在点击时获得焦点，所以 iOS 和 Android 都会弹出键盘。
- textarea 使用 16px 字号，iOS 不会缩放页面。
- 键盘弹出、页面滚动时，textarea 始终在光标处，输入法会在正确的位置显示候选词。

`detach()` 会恢复 Fabric 自己的处理方式。

## 键盘配置

```ts
attachMobileTextInput(canvas, {
  inputMode: "text",
  enterKeyHint: "done",
  autocapitalize: "sentences",
  autocorrect: true,
});
```

| 配置项           | 默认值        |
| ---------------- | ------------- |
| `inputMode`      | `"text"`      |
| `enterKeyHint`   | `"enter"`     |
| `autocapitalize` | `"sentences"` |
| `autocorrect`    | `true`        |
| `spellcheck`     | `false`       |

Fabric 关闭了自动更正，因为它自己的处理无法正确应用更正。有了适配器，可以放心重新打开自动更正。用代码修改文字，请看[用代码修改文字样式](https://fabricjs-document-engine.jscrate.dev/zh/docs/guides/text-styles)。所有类型列在[文字 API](https://fabricjs-document-engine.jscrate.dev/zh/docs/api/text-api)里，Fabric.js 移动端的文字输入就完整了。
