资讯详情 Fabric.js中IText实现文本溢出自动换行
📅 2026/10/28 19:02:22
template div classcontainer h1Fabric.js IText 自动换行示例fontSize {{ FONT_SIZE }}宽度超过 {{ MAX_TEXT_WIDTH }} 自动换行/h1 div classtoolbar button clickaddText添加文本/button button clickdeleteSelected删除选中/button button clickclearCanvas清空画布/button /div canvas idcanvas width900 height600/canvas p classdebug 当前文本⏎ 自动换行标记 U2028¶ 手动回车 \ncode{{ displayText }}/code /p /div /template script setup import { ref, computed, onMounted, onBeforeUnmount } from vue import { Canvas, IText } from fabric const FONT_SIZE 50 const MAX_TEXT_WIDTH 620 // 自动换行标记Unicode 行分隔符 U2028视觉不可见。 // 手动按回车产生 \n自动换行插入 \u2028两者可以区分。 const AUTO_BREAK String.fromCharCode(0x2028) // U2028 行分隔符视觉不可见 let canvas null // 底部实时预览把两种换行符号化显示便于区分 const rawText ref() const displayText computed(() rawText.value .replace(new RegExp(AUTO_BREAK, g), ⏎) .replace(/\n/g, ¶) ) /** * 重新计算自动换行 * 1. 剥离所有旧的自动换行标记 \u2028它们只是软换行位置随编辑失效 * 2. 以手动换行 \n 为段落边界逐单元累加行宽fontSize 下像素宽度 * 超过 MAX_TEXT_WIDTH 时在行尾空白之后插入 \u2028 * 不可拆分单元连续西文字符英文字母/数字/符号组成的单词 * 中文等其他字符逐字切开空白单独成单元 * 返回 { text, map }map[i] 新文本第 i 个字符对应的原文本位置 * 被剥离的 \u2028 不在 map 中用于光标映射 */ const isWhitespace (ch) /\s/.test(ch) const isWestern (ch) { const cp ch.codePointAt(0) return !isWhitespace(ch) cp 0x2e80 } // 用离屏 canvas 测量文本宽度与 IText 同样的 fontSize/fontFamily let measureCtx null function createMeasurer(fontSize, fontFamily) { if (!measureCtx) measureCtx document.createElement(canvas).getContext(2d) measureCtx.font ${fontSize}px ${fontFamily} return (s) measureCtx.measureText(s).width } function formatText(text, measure) { const all Array.from(text) // 剥离旧标记保留原索引供光标映射 const chars [] for (let i 0; i all.length; i) { if (all[i] ! AUTO_BREAK) chars.push({ ch: all[i], idx: i }) } // 切分不可拆分的单元 const units [] for (let i 0; i chars.length; ) { const ch chars[i].ch if (isWhitespace(ch)) { units.push({ chars: [chars[i]], isBreak: ch \n }) i } else if (isWestern(ch)) { // 连续西文字符 一个英文单词不可从中间断开 const group [] while (i chars.length isWestern(chars[i].ch)) group.push(chars[i]) units.push({ chars: group, isBreak: false }) } else { // 中文等其他字符逐字切开 units.push({ chars: [chars[i]], isBreak: false }) i } } const formatted [] const map [] let line // 当前行已累计文本含行尾空白 let lastIdx -1 // 最后输出的字符的原文本索引 const push (c) { formatted.push(c.ch) map.push(c.idx) lastIdx c.idx } for (const unit of units) { if (unit.isBreak) { for (const c of unit.chars) push(c) line continue } const unitText unit.chars.map((c) c.ch).join() // 当前行已有内容且加入该单元后超宽 → 换行标记插在行尾空白之后 if (line.trim() ! measure(line unitText) MAX_TEXT_WIDTH) { formatted.push(AUTO_BREAK) map.push(lastIdx) line } // 单元自身超宽如连续输入的长数字/长单词→ 在单元内部逐字符强制断行 // 否则该单元永远不会被拆开表现为文本溢出且不换行 if (measure(unitText) MAX_TEXT_WIDTH) { for (const c of unit.chars) { if (line ! measure(line c.ch) MAX_TEXT_WIDTH) { formatted.push(AUTO_BREAK) map.push(c.idx) line } push(c) line c.ch } continue } for (const c of unit.chars) push(c) line unitText } return { text: formatted.join(), map } } /** * 监听 IText 的 changed 事件在文本中插入 \u2028自动换行标记实现自动换行。 * 通过扩展实例的 _reNewline 让 fabric 把 \u2028 也当作换行渲染 * 光标/选区等编辑逻辑依然由 fabric 原生处理。 */ function onTextChanged(itText) { if (!itText || !itText.isEditing) return // 输入法组合输入如中文拼音期间跳过 // 此时改写 textarea.value 会打断输入法会话导致光标错乱 // 且 fabric 组合期间也不会写回光标位置。 // 组合结束由 compositionend 监听器补做换行。 if (itText.inCompositionMode || itText.hiddenTextarea?.isComposing) return rawText.value itText.text const measure createMeasurer(itText.fontSize, itText.fontFamily) const { text: newText, map } formatText(itText.text, measure) if (newText itText.text) return // 将原始光标位置映射到新文本中的位置 const cursor itText.selectionStart let newCursor map.length for (let j 0; j map.length; j) { if (map[j] cursor) { newCursor j break } newCursor j 1 } // 记录编辑锚点位置与 fabric updateFromTextArea 一致按 textAlign 取水平锚点、top 垂直锚点。 // fabric 7 默认 origin 是 center/centerinitDimensions 改变尺寸后若不重新固定锚点 // 对象会向四周扩展表现为位置漂移 const anchorX itText.textAlign justify ? left : itText.textAlign.replace(justify-, ) const anchorPos itText.getPointByOrigin(anchorX, top) itText.text newText itText.selectionStart itText.selectionEnd newCursor itText.initDimensions() itText.setPositionByOrigin(anchorPos, anchorX, top) // 同步隐藏输入框内容并让 fabric 把光标位置写回 textarea // 否则 textarea 光标会重置到末尾下次输入时光标跳到最后 if (itText.hiddenTextarea) itText.hiddenTextarea.value newText itText._updateTextarea() itText.setCoords() canvas.requestRenderAll() } function addText() { const text new IText(双击编辑文字宽度超过620会自动换行, { left: 80, top: 60, fontSize: FONT_SIZE, fill: #333, editingBorderColor: #4a90d9, padding: 6 }) // 让 fabric 把 \u2028 也识别为换行分行、渲染、光标跨行移动 // 与手动回车的 \n 一样处理但两者在文本中可区分。 // 注意 \r? 必须单独的 \n手动回车也要能匹配 text._reNewline /\r?\n|\u2028/ // 初始文本按最大宽度预格式化用对象自身的字体设置测量 text.text formatText(text.text, createMeasurer(text.fontSize, text.fontFamily)).text text.initDimensions() // fabric 7 中对象级 changed 事件不携带参数通过闭包传入文本对象 text.on(changed, () onTextChanged(text)) // 中文输入法提交compositionend后立即补做换行 text.on(editing:entered, () { const ta text.hiddenTextarea if (!ta) return text._onCompositionEnd () onTextChanged(text) ta.addEventListener(compositionend, text._onCompositionEnd) // 捕获阶段拦截 Backspace // 光标在软换行行首时前一个字符是 \u2028 标记。 // fabric 会直接删掉它但随后的重排又在同一位置插回同样的标记 // 表现为按删除键删不掉。这里改为等效删除上一行行尾的真实 // 字符连同 \u2028 一起删除再交由 onTextChanged 重新排版 // 与常规编辑器软换行处的 Backspace 行为一致。 text._onKeyDownCapture (e) { if (e.key ! Backspace || text.inCompositionMode) return const start text.selectionStart if (start ! text.selectionEnd || start 0) return if (text.text[start - 1] ! AUTO_BREAK) return e.preventDefault() e.stopImmediatePropagation() const removeStart Math.max(0, start - 2) text.text text.text.slice(0, removeStart) text.text.slice(start) text.selectionStart text.selectionEnd removeStart onTextChanged(text) } ta.addEventListener(keydown, text._onKeyDownCapture, true) }) text.on(editing:exited, () { const ta text.hiddenTextarea if (ta) { if (text._onCompositionEnd) { ta.removeEventListener(compositionend, text._onCompositionEnd) text._onCompositionEnd null } if (text._onKeyDownCapture) { ta.removeEventListener(keydown, text._onKeyDownCapture, true) text._onKeyDownCapture null } } rawText.value text.text }) canvas.add(text) canvas.setActiveObject(text) rawText.value text.text canvas.requestRenderAll() } function deleteSelected() { const active canvas.getActiveObject() if (!active) return active.off?.(changed) canvas.remove(active) canvas.discardActiveObject() canvas.requestRenderAll() } function clearCanvas() { for (const obj of canvas.getObjects()) { obj.off?.(changed) } canvas.remove(...canvas.getObjects()) canvas.discardActiveObject() canvas.requestRenderAll() } function onKeyDown(e) { if (!canvas) return if (e.key Delete || e.key Backspace) { const active canvas.getActiveObject() if (!active || active.isEditing) return deleteSelected() } } onMounted(() { canvas new Canvas(canvas, { backgroundColor: #ffffff, preserveObjectStacking: true }) if (import.meta.env.DEV) window.__fabricCanvas canvas addText() window.addEventListener(keydown, onKeyDown) }) onBeforeUnmount(() { window.removeEventListener(keydown, onKeyDown) canvas?.dispose() canvas null }) /script style scoped .container { max-width: 960px; margin: 0 auto; padding: 24px; } h1 { font-size: 20px; margin-bottom: 16px; color: #2c3e50; } .toolbar { display: flex; gap: 12px; margin-bottom: 16px; } .toolbar button { padding: 8px 16px; border: 1px solid #d0d5dd; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px; } .toolbar button:hover { background: #f0f4ff; border-color: #4a90d9; } canvas { border: 1px solid #d0d5dd; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .debug { margin-top: 12px; font-size: 13px; color: #666; word-break: break-all; } .debug code { color: #c7254e; background: #f9f2f4; padding: 1px 4px; border-radius: 3px; } /style