Java+微信小程序实现高校图书馆智能预约系统
2026/10/28 19:35:33
<template> <div class="container"> <h1>Fabric.js IText 自动换行示例(fontSize {{ FONT_SIZE }},宽度超过 {{ MAX_TEXT_WIDTH }} 自动换行)</h1> <div class="toolbar"> <button @click="addText">添加文本</button> <button @click="deleteSelected">删除选中</button> <button @click="clearCanvas">清空画布</button> </div> <canvas id="canvas" width="900" height="600"></canvas> <p class="debug"> 当前文本(⏎ = 自动换行标记 U+2028,¶ = 手动回车 \n):<code>{{ 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 行分隔符 U+2028(视觉不可见)。 // 手动按回车产生 \n,自动换行插入 \u2028,两者可以区分。 const AUTO_BREAK = String.fromCharCode(0x2028) // U+2028 行分隔符,视觉不可见 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/center,initDimensions 改变尺寸后若不重新固定锚点, // 对象会向四周扩展,表现为位置漂移 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>