You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React中实现HTML Canvas输入时文字自适应圆形(带缩放)的问题

React中实现HTML Canvas输入时文字自适应圆形(带缩放)的问题

我懂你想要实现的效果——输入文字时让内容自动适配固定圆形的排版,试过Mike Bostock的教程和markE的旧方案但都卡壳了对吧?咱们先拆解你遇到的问题,再给出能跑通的修正方案。

你的两个尝试里的核心问题

第一个尝试的卡点

  1. State更新时机问题:你在handleChange里先用了setText(newText),紧接着就用text.split,但React的State更新是异步的,这时候text还是旧值,得直接用newText来处理。
  2. 文字圆形排列逻辑缺失:你最后只是把文字按普通行绘制,没有结合圆形的弧度/弦长来计算每行的位置和宽度,这就没法实现“适配圆形”的效果。

第二个尝试的问题

  1. 未处理边界情况:calcAllowableWords函数在所有文字都能塞进一行时没有返回值,导致lineData变成undefined,访问它的属性就会抛出“text undefined”的错误。
  2. 坐标计算偏差:绘制文字时的Y轴坐标计算有点问题,而且没有在每次输入时清空画布,导致旧文字会残留。
  3. 逻辑倒置:你提到的“半径适配文字而非文字适配半径”,是因为原来的方案默认是让圆跟着文字走,咱们需要改成固定圆半径,让文字去适配这个固定的圆。

修正后的完整实现方案

下面是调整后的代码,解决了上面的所有问题,实现了输入时文字自动适配固定圆形的效果:

import React, { useEffect, useRef, useState } from "react";

export const TwoPI = 2 * Math.PI;

export function setupGridWidthHeightAndScale(
  width: number,
  height: number,
  canvas: HTMLCanvasElement
) {
  canvas.style.width = width + "px";
  canvas.style.height = height + "px";
  const scale = window.devicePixelRatio;
  canvas.width = width * scale;
  canvas.height = height * scale;
  const canvasCtx = canvas.getContext("2d")!;
  canvasCtx.scale(scale, scale);
}

type CanvasProps = {
  width: number;
  height: number;
};

export function TextInCircle({ width, height }: CanvasProps) {
  const [typedText, setTypedText] = useState("");
  const canvasRef = useRef<HTMLCanvasElement>(null);

  function getContext() {
    const canvas = canvasRef.current!;
    return canvas.getContext("2d")!;
  }

  // 初始化Canvas和绘制背景+固定圆形
  useEffect(() => {
    const canvas = canvasRef.current!;
    setupGridWidthHeightAndScale(width, height, canvas);
    drawBaseCircle();
  }, [width, height]);

  // 绘制基础的固定圆形
  const drawBaseCircle = () => {
    const ctx = getContext();
    ctx.clearRect(0, 0, width, height);
    // 背景
    ctx.fillStyle = "black";
    ctx.fillRect(0, 0, width, height);
    // 固定半径的圆形
    const cx = width / 2;
    const cy = height / 2;
    const r = 100;
    ctx.beginPath();
    ctx.arc(cx, cy, r, 0, TwoPI);
    ctx.closePath();
    ctx.fillStyle = "white";
    ctx.fill();
    // 圆形边框(可选)
    ctx.strokeStyle = "#333";
    ctx.lineWidth = 2;
    ctx.stroke();
  };

  // 计算圆形上每行的最大宽度(弦长)和Y轴位置
  const getCircleLines = (r: number, lineHeight: number) => {
    const cx = width / 2;
    const cy = height / 2;
    const lines: { y: number; maxWidth: number }[] = [];
    // 从圆形顶部往下遍历,计算每行的弦长
    for (let yOffset = -r + lineHeight; yOffset < r - lineHeight; yOffset += lineHeight) {
      // 计算当前行到圆心的垂直距离
      const dy = Math.abs(yOffset);
      // 弦长公式:2*sqrt(r² - dy²)
      const chordLength = 2 * Math.sqrt(r * r - dy * dy);
      if (chordLength > 10) { // 过滤太窄的行
        lines.push({
          y: cy + yOffset,
          maxWidth: chordLength
        });
      }
    }
    return lines;
  };

  // 将文字拆分为适配每行宽度的行
  const splitTextToLines = (text: string, lines: { y: number; maxWidth: number }[]) => {
    const ctx = getContext();
    ctx.font = "12px Arial";
    const words = text.trim().split(/\s+/);
    const resultLines: { y: number; text: string }[] = [];
    let currentLine = "";

    for (const word of words) {
      const testLine = currentLine ? `${currentLine} ${word}` : word;
      const testWidth = ctx.measureText(testLine).width;
      // 找到当前可用的行
      const targetLine = resultLines.length < lines.length ? lines[resultLines.length] : null;
      if (!targetLine) break; // 没有更多行可以放了

      if (testWidth <= targetLine.maxWidth) {
        currentLine = testLine;
      } else {
        if (currentLine) {
          resultLines.push({ y: targetLine.y, text: currentLine });
        }
        // 如果单个单词就超过行宽,直接截断(或者你可以做 hyphenation 处理)
        const truncatedWord = word.slice(0, Math.floor(targetLine.maxWidth / ctx.measureText("a").width));
        resultLines.push({ y: targetLine.y, text: truncatedWord + "..." });
        currentLine = "";
      }
    }

    // 把最后一行加进去
    if (currentLine && resultLines.length < lines.length) {
      resultLines.push({ y: lines[resultLines.length].y, text: currentLine });
    }

    return resultLines;
  };

  // 绘制适配圆形的文字
  const drawTextInCircle = (text: string) => {
    const ctx = getContext();
    const r = 100;
    const lineHeight = 18; // 行高(包含间距)
    const lines = getCircleLines(r, lineHeight);
    const textLines = splitTextToLines(text, lines);

    // 清空画布后重绘基础圆形
    drawBaseCircle();

    // 设置文字样式
    ctx.font = "12px Arial";
    ctx.fillStyle = "black";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";

    // 绘制每一行文字
    textLines.forEach(line => {
      ctx.fillText(line.text, width / 2, line.y);
    });
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newText = e.target.value;
    setTypedText(newText);
    drawTextInCircle(newText);
  };

  return (
    <>
      <input 
        type="text" 
        onChange={handleChange} 
        placeholder="输入文字试试..."
        style={{ marginBottom: "10px", padding: "8px" }}
      />
      <canvas ref={canvasRef}></canvas>
    </>
  );
}

关键逻辑说明

  1. 固定圆形半径:我们先绘制一个固定半径的白色圆形,所有文字都要适配这个圆形的范围。
  2. 弦长计算:用几何公式计算圆形上每行的最大宽度(弦长),确保文字不会超出圆形边界。
  3. 文字分行适配:把输入的文字拆分成适配每行弦长的内容,单个单词过长时会自动截断。
  4. 实时重绘:每次输入时清空画布,重绘圆形和文字,避免旧内容残留。
  5. 高清Canvas处理:保留了你原来的setupGridWidthHeightAndScale函数,解决Canvas模糊的问题。

这样调整后,你输入文字时,内容就会自动适配固定圆形的排版啦~

备注:内容来源于stack exchange,提问作者psygo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.21 07:14:36