图片占位符
需求:tinymce 编辑器中需要一个图片占位符,上面展示一些配置项,由后端根据配置项完成图片替换,前端可双击弹框修改配置项。
思路
- 绘制文字:使用 CanvasRenderingContext2.fillText() 绘制文本,使用 CanvasRenderingContext2D.measureText() 来判断一行长文字何时换行展示。
- 保存配置项数据:使用 data-json 自定义属性保存配置项,前端根据 data-json 动态生成图片占位符的 base64 替换页面中图片占位符的 src 属性;后端可根据 data-json 生成真实数据,或修改图片占位符。
- 双击修改:监听双击事件,过滤图片占位符,从 data-json 中取出数据回填到弹框中;每个图片占位符绑定一个 mceId,用于修改时回填配置项数据和重新生成 base64。
文本占位符
需求
用户在弹框中选择选项,确认后将在 tinymce 编辑器中插入一张图片占位符,上面展示刚才选择的选项,有后端根据配置项完成图片替换,前端可双击弹框修改配置项。
图片占位符上展示”标题” 和一行文字。例如 @{xxx/yyy/zzz}
实现
参考 canvas 文本绘制自动换行、字间距、竖排等实现:将文本分割为字符串,逐渐加字并测量长度,超过指定宽度时换行绘制。
写的过程中发现 MDN fillText 的中文翻译有歧义:fillText 参数 y 的中文描述歧义。good first issue 参与大型开源项目了,属于是 : )
/**
* @description 文字转Base64
* @param {*} content 文字
* @param {*} width 生成图片的宽度
* @param {*} height 生成图片的高度
* @returns base64
*/
export const text2img = (content, options = {}) => {
const _options = {
width: 635, // px
height: 152, //px
fontSize: 16, //px
fontColor: "#409EFF",
fontFamily: "serif",
backgroundColor: "#F4F4F5",
title: "【xx统计图】",
imgType: "image/webp",
encoderOptions: 0.6,
...options,
};
const canvas = document.createElement("canvas");
canvas.width = _options.width;
canvas.height = _options.height;
const ctx = canvas.getContext("2d");
// background
ctx.fillStyle = `${_options.backgroundColor}`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// font
ctx.font = `${_options.fontSize}px ${_options.fontFamily}`;
ctx.fillStyle = `${_options.fontColor}`;
// calc number of words per line
let wordsPerLine = content.length;
const charList = content.split("");
for (let i = 0; i < charList.length; i++) {
const text = ctx.measureText(content.slice(0, i + 1));
if (text.width >= _options.width - _options.fontSize) {
wordsPerLine = i + 1;
break;
}
}
// split content into lines
const lines = [];
for (let i = 0; i < Math.ceil(content.length / wordsPerLine); i++) {
if (i === Math.ceil(content.length / wordsPerLine) - 1) {
lines.push(content.slice(i * wordsPerLine));
} else {
lines.push(content.slice(i * wordsPerLine, (i + 1) * wordsPerLine));
}
}
// draw title
if (_options.title) {
ctx.font = `bold ${_options.fontSize}px ${_options.fontFamily}`;
ctx.fillText(
_options.title,
10,
10 + _options.fontSize,
_options.width - _options.fontSize
);
}
ctx.font = `${_options.fontSize}px ${_options.fontFamily}`;
// draw content
let x = 10;
if (lines.length === 1) {
const text = ctx.measureText(lines[0]);
x = canvas.width >= text.width ? (canvas.width - text.width) / 2 : 10;
}
const lineMarginBottom = 10;
const linesHeight = (_options.fontSize + lineMarginBottom) * lines.length;
const y = (canvas.height - linesHeight) / 2 + _options.fontSize;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
ctx.fillText(
line,
i === 0 ? x : 4,
y + i * (_options.fontSize + lineMarginBottom),
_options.width - _options.fontSize
);
}
return canvas
.toDataURL(_options.imgType, _options.encoderOptions)
.replace(/\s/g, "");
};
粘贴占位符
场景: 用户可能会复制、剪切、粘贴生成的图片占位符,若直接复制粘贴,tinymce 中会有两个一样的 mceId,无法完成双击修改配置项。
万幸!TinyMce 提供了粘贴处理事件 paste_postprocess,在这里检查粘贴的 mceId 是否已存在,已存在则更新。更巧的是,此时 tinymce 内容是复制/剪切后的,即剪切(ctrl+x)时原内容已去除了,直接粘贴即可。
paste_postprocess
This option enables you to modify the pasted content before it gets inserted into the editor, but after it’s been parsed into a DOM structure.
/** 粘贴事件:粘贴的元素id唯一 */
async onPastePostprocess(editor, args) {
const root = args.node
// 去除重复id
const mceIdElementList = root.querySelectorAll('[data-mce-id]')
if (mceIdElementList && mceIdElementList.length) {
const currentTimestamp = Date.now()
mceIdElementList.forEach((e, index) => {
if (document.getElementById(e.id)) {
const newMceId = TinyUtil.getNewMecId(e.dataset.mceId, `${currentTimestamp}_${index}`)
e.id = e.dataset.mceId = newMceId
}
})
}
}
/** 业务工具 */
export class TinyUtil {
static getNewMecId(oldMceId, newUuid) {
const idComponents = oldMceId.split('-')
idComponents[4] = newUuid
return idComponents.join('-')
}
}
一行 n 个
需求变了:用户在系统弹框中选择了一些配置项,请求后端接口返回一系列配置项,要求展示这些图片占位符并且可以自定义一行展示几个(一行可选个数:1、2、3、4)。
- 批量生成图片占位符:循环生成即可。
- 一行 n 个:这些图片占位符外层套一层 grid 布局;由于 tinymce 宽度是确定的,可根据每行展示个数确认图片占位符的宽度,并修改字体大小以
美观不丑陋。
外层的 grid 布局会影响到输入正常内容,如在 grid 内部回车换行后输入新内容,则新行也存在 grid 样式,十分冗余难受。
可监听 tinymce 的 NewBlock 事件,以处理新行样式。
// grid 样式
const wrapper = document.createElement('p')
wrapper.classList.add('img-holder-grid-wrapper')
wrapper.style.display = 'grid'
wrapper.style.gridTemplateColumns = new Array(placeholder.columns).fill('1fr').join(' ')
wrapper.style.gap = '5px'
wrapper.dataset.type = 'lineWrap'
// 图片大小
width: Math.floor(635 / columns),
height: Math.floor(152 - (columns - 1) * 10),
fontSize: 16 - (columns - 1) * 2,
表单占位符
需求
在图片占位符上展示更多的信息,最好能把自定义弹框里的选项都展示出来,文字拼接展示太丑了。
设计与实现
第三方库 Fabric.js 有文字换行功能,拿来就用!
让后端把配置项整理成数组形式 [{label, content}, {label, content}]
import {fabric} from 'fabric'
export class TinyUtil {
/** 生成图片占位符 */
static genImgHolderByForm({caption, formCfg, columns = 1}) {
const img = document.createElement('img')
img.classList.add('img-holder')
img.classList.add('mce-img-holder')
img.src = TinyUtil.form2img(typeof formCfg === 'string' ? JSON.parse(formCfg) : formCfg, {
caption: caption,
imgWidth: Math.floor(635 / columns),
imgHeight: Math.floor(152 - (columns - 1) * 10),
fontSize: 16 - (columns - 1) * 2,
})
return img
}
/** 图片表单 */
static form2img(formCfg, params) {
const options = {
imgWidth: params?.imgWidth || 300,
imgHeight: params?.imgHeight || 150,
fontFamily: 'serif',
fontSize: params?.fontSize || 14,
fontColor: params?.fontColor || '#409EFF',
backgroundColor: params?.backgroundColor || '#F4F4F5',
padding: params.padding || 10,
labelWidth: (params?.fontSize || 14) * 2,
}
const canvas = new fabric.Canvas('canvasBox', {
width: options.imgWidth,
height: options.imgHeight,
backgroundColor: options.backgroundColor,
})
let nextTop = 20
// draw caption
if (params?.caption) {
const caption = new fabric.Text(params?.caption, {
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fontWeight: 'bold',
fill: options.fontColor,
left: 0,
top: 10,
})
canvas.add(caption)
nextTop = 10 + caption.height + 10
}
// get max label width
let labelWidth = options.labelWidth
formCfg.forEach((item) => {
const measureTabel = new fabric.Textbox(`${item.label}:`, {
left: options.padding,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
textAlign: 'right',
top: nextTop,
width: labelWidth,
})
if (labelWidth < measureTabel.width) {
labelWidth = measureTabel.width
}
})
// draw from
formCfg.forEach((item) => {
const itemLabel = new fabric.Textbox(`${item.label}:`, {
left: options.padding,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
textAlign: 'right',
top: nextTop,
width: labelWidth,
})
const itemContent = new fabric.Textbox(item.content, {
left: options.padding + labelWidth,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
top: nextTop,
width: canvas.width - 2 * options.padding - labelWidth,
splitByGrapheme: true, // 自动换行
})
nextTop += itemContent.height + 10
canvas.add(itemLabel)
canvas.add(itemContent)
})
// adjust img height
if (nextTop > options.imgHeight) {
options.imgHeight = Math.ceil(nextTop + 10)
canvas.setHeight(options.imgHeight)
}
return canvas.toDataURL()
}
}
异步优化
前置场景
系统提供了文章预览功能,展示一系列的文章标题,鼠标悬浮时展示文章内容(文章内放入图片也要展示)。
方案是:前端直接 v-html 展示算了,正好 tinymce 保存的就是 html 格式,请求到数据后生成图片占位符再塞到 v-html 中。
// 请求文章列表
let tempList = []
list(queryData)
.then(res => { tempList = res.data})
.finally(() => {
tempList.map(item => {
return {
...item,
content: this.renderContentImgHolder(item.content) // 存放 html 字符串
}
})
})
renderContentImgHolder(item) {
const div = document.createElement('div')
div.innerHTML = item.content
const imgs = div.querySelectorAll('.mce-img-holder')
imgs.forEach((e, index) => {
if (e?.dataset?.placeholder) {
const img = TinyUtil.genImgHolderByForm(JSON.parse(e.dataset.placeholder))
e.src = img.src
}
}
return div.innerHTML
}
异步优化
有几篇文章插入了上百个图片占位符,回显单个文章有卡顿感,展示文章缩略图时甚至能卡黑屏。
测试项目中3.2方案 191 张图片占位符用了 7233ms,cpu 一下子就满了。
那就改成异步的:加载时先展示空白图片占位符(生成一次url, 不耗时),在 worker 线程中批量生成图片占位符(避免页面卡顿),在主线程中接收占位符的 url 并回填(使用 mceId 查找元素,替换 src)。Fabric 不支持在 worker 中,只能自己用 OffscreenCanvas 写。
页面中将数据发送给 worker 处理,监听自定义事件处理数据回填即可。
下附生成占位符的性能对比 (100 张图片)
方案 | 轮次 | 耗时(ms) | 平均耗时(ms) |
---|---|---|---|
OffscreenCanvas (await) | 1 | 519 | 538 |
… | 2 | 547 | |
… | 3 | 549 | |
OffscreenCanvas (promise) | 1 | 510 | 513 |
… | 2 | 518 | |
… | 3 | 513 | |
Canvas | 1 | 419 | 421 |
… | 2 | 422 | |
… | 3 | 422 | |
fabric | 1 | 1589 | 1571 |
… | 2 | 1541 | |
… | 3 | 1584 |
测试程序
import { TinyCanvas } from "./../lib/main";
import { fabric } from "fabric";
const FORM_ITEMS = [
{
label: "活动名称",
content:
"活动名称活动名称活动名称活动名称活动名称活动名称活动名称活动名称活动名称",
},
{
label: "活动区域",
content: "区域一",
},
{
label: "活动时间",
content: "2023-09-01 ~ 2023-09-02",
},
{
label: "活动性质",
content: "美食/餐厅线上活动、地推活动、线下主题活动",
},
{
label: "特殊资源",
content: "线上品牌商赞助、线下场地免费",
},
{
label: "备注",
content: "备注",
},
];
const btn: HTMLButtonElement = document.querySelector("#btn")!;
btn.onclick = () => {
// testOffscreenCanvas();
// await testOffscreenCanvasAwait();
// testCanvas();
testFabric();
};
function testOffscreenCanvas() {
const timeCalc = {
offscrrenCanvas: 0,
};
const imgNum = 100;
let count = 0;
let startTime = Date.now();
const formItems = JSON.parse(JSON.stringify(FORM_ITEMS));
for (let i = 0; i < imgNum; i++) {
formItems[5].content = String("备注").repeat(i % 10);
const canvas = new TinyCanvas({
width: 600,
height: 150,
});
canvas.renderForm("【xxx图】", formItems).then((url) => {
count++;
if (count === imgNum) {
timeCalc.offscrrenCanvas = Date.now() - startTime;
console.table(timeCalc);
}
});
}
}
async function testOffscreenCanvasAwait() {
const timeCalc = {
offscrrenCanvas: 0,
};
const imgNum = 100;
let startTime = Date.now();
const formItems = JSON.parse(JSON.stringify(FORM_ITEMS));
for (let i = 0; i < imgNum; i++) {
formItems[5].content = String("备注").repeat(i % 10);
const canvas = new TinyCanvas({
width: 600,
height: 150,
});
await canvas.renderForm("【xxx图】", formItems);
}
timeCalc.offscrrenCanvas = Date.now() - startTime;
console.table(timeCalc);
}
function testCanvas() {
const timeCalc = {
offscrrenCanvas: 0,
};
const imgNum = 100;
let startTime = Date.now();
const formItems = JSON.parse(JSON.stringify(FORM_ITEMS));
for (let i = 0; i < imgNum; i++) {
formItems[5].content = String("备注").repeat(i % 10);
const canvas = new TinyCanvas({
width: 600,
height: 150,
isOffscreen: false,
});
canvas.renderFormSync("【xxx图】", formItems);
}
timeCalc.offscrrenCanvas = Date.now() - startTime;
console.table(timeCalc);
}
function testFabric() {
const timeCalc = {
fabric: 0,
};
const params = {
imgWidth: 600,
imgHeight: 150,
caption: "【xxx图】",
};
const imgNum = 100;
let startTime = Date.now();
const formItems = JSON.parse(JSON.stringify(FORM_ITEMS));
for (let i = 0; i < imgNum; i++) {
formItems[5].content = String("备注").repeat(i % 10);
form2img(formItems, params);
}
timeCalc.fabric = Date.now() - startTime;
console.table(timeCalc);
}
/** 图片表单 */
function form2img(formCfg, params) {
const options = {
imgWidth: params?.imgWidth || 300,
imgHeight: params?.imgHeight || 150,
fontFamily: "serif",
fontSize: params?.fontSize || 14,
fontColor: params?.fontColor || "#409EFF",
backgroundColor: params?.backgroundColor || "#F4F4F5",
padding: params.padding || 10,
labelWidth: (params?.fontSize || 14) * 2,
};
const canvas = new fabric.Canvas("canvasBox", {
width: options.imgWidth,
height: options.imgHeight,
backgroundColor: options.backgroundColor,
});
let nextTop = 20;
// draw caption
if (params?.caption) {
const caption = new fabric.Text(params?.caption, {
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fontWeight: "bold",
fill: options.fontColor,
left: 0,
top: 10,
});
canvas.add(caption);
nextTop = 10 + caption.height + 10;
}
// get max label width
let labelWidth = options.labelWidth;
formCfg.forEach((item) => {
const measureTabel = new fabric.Textbox(`${item.label}:`, {
left: options.padding,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
textAlign: "right",
top: nextTop,
width: labelWidth,
});
if (labelWidth < measureTabel.width) {
labelWidth = measureTabel.width;
}
});
// draw from
formCfg.forEach((item) => {
const itemLabel = new fabric.Textbox(`${item.label}:`, {
left: options.padding,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
textAlign: "right",
top: nextTop,
width: labelWidth,
});
const itemContent = new fabric.Textbox(item.content, {
left: options.padding + labelWidth,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fill: options.fontColor,
top: nextTop,
width: canvas.width - 2 * options.padding - labelWidth,
splitByGrapheme: true, // 自动换行
});
nextTop += itemContent.height + 10;
canvas.add(itemLabel);
canvas.add(itemContent);
});
// adjust img height
if (nextTop > options.imgHeight) {
options.imgHeight = Math.ceil(nextTop + 10);
canvas.setHeight(options.imgHeight);
}
return canvas.toDataURL();
}
图片表单
interface ITinyCanvasOptions {
/**
* 图片宽度
*/
width: number;
/**
* 图片高度
*/
height: number;
/**
* 是否离屏渲染
*/
isOffscreen?: boolean;
/**
* 背景色
*/
backgroundColor?: string | CanvasGradient | CanvasPattern;
/**
* base64参数
*/
blobOptions?: IBlobOptions;
/**
* 字号
*/
fontSize?: number;
/**
* 字体颜色
*/
fontColor?: string;
/**
* 字体
*/
fontFamily?: string;
}
interface IBlobOptions {
type?: string;
quality?: number;
}
interface IFormItem {
label: string;
content: string | number | boolean;
labelWidth?: number;
contentHeight?: number;
}
interface ITextOptions {
left: number;
top: number;
width: number;
}
const DefaultTinyCanvasOptions: ITinyCanvasOptions = {
width: 100,
height: 100,
isOffscreen: false,
backgroundColor: "#F4F4F5",
fontSize: 16,
fontColor: "#409EFF",
fontFamily: "serif",
};
export class TinyCanvas {
private canvas: OffscreenCanvas | HTMLCanvasElement;
private ctx:
| OffscreenCanvasRenderingContext2D
| CanvasRenderingContext2D
| null;
private options: ITinyCanvasOptions;
constructor(options?: ITinyCanvasOptions) {
this.options = { ...DefaultTinyCanvasOptions, ...options };
this.canvas = this.options.isOffscreen
? new OffscreenCanvas(this.options.width, this.options.height)
: document.createElement("canvas");
this.ctx = this.canvas.getContext("2d")!;
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
}
public async renderForm(
title: string,
formItems: IFormItem[]
): Promise<string> {
const { imgHeight, labelWidth } = this.drawFrom(title, formItems);
if (imgHeight > this.canvas.height) {
this.canvas.height = imgHeight;
this.drawFrom(title, formItems, labelWidth);
}
if (this.options.isOffscreen) {
const blob = await (this.canvas as OffscreenCanvas).convertToBlob();
return new Promise((reslove) => {
const fileReader = new FileReader();
fileReader.onload = () => {
reslove(String(fileReader.result));
};
fileReader.readAsDataURL(blob);
});
} else {
return new Promise((reslove) => {
reslove((this.canvas as HTMLCanvasElement).toDataURL());
});
}
}
public renderFormSync(title: string, formItems: IFormItem[]): string {
if (this.options.isOffscreen) {
throw new Error("[TinyUtil]: renderFormSync is undefined when offscreen");
}
const { imgHeight, labelWidth } = this.drawFrom(title, formItems);
if (imgHeight > this.canvas.height) {
this.canvas.height = imgHeight;
this.drawFrom(title, formItems, labelWidth);
}
return (this.canvas as HTMLCanvasElement).toDataURL();
}
public resetHeight(height?: number) {
this.canvas.height = height || this.options.height;
}
private drawFrom(
title: string,
formItems: IFormItem[],
_labelWidth?: number
): { imgHeight: number; labelWidth: number } {
this.drawBackground();
// 最长标签宽度
let labelWidth = _labelWidth || 0;
if (!_labelWidth) {
formItems.forEach((item) => {
const label = this.ctx?.measureText(item.label + ":");
item.labelWidth = label?.width;
if (label?.width && label?.width > labelWidth) {
labelWidth = label?.width;
}
});
}
let nextTop = 10;
let padding = 10;
// 题注
this.ctx!.font = `bold ${this.options.fontSize}px ${this.options.fontFamily}`;
const titleHeight = this.drawText(title, {
left: 0,
top: nextTop + this.options.fontSize!,
width: this.canvas.width - padding,
});
nextTop += titleHeight + this.options.fontSize! / 2;
// 表单
this.ctx!.font = `${this.options.fontSize}px ${this.options.fontFamily}`;
formItems.forEach((item) => {
const labelLeft = padding + labelWidth - item.labelWidth!;
const labelTop = nextTop + this.options.fontSize!;
this.ctx?.fillText(item.label + ":", labelLeft, labelTop);
const contentHeight = this.drawText(String(item.content), {
left: padding + labelWidth,
top: labelTop,
width: this.canvas.width - 2 * padding - Math.ceil(labelWidth),
});
nextTop += contentHeight + padding;
});
return { imgHeight: nextTop, labelWidth };
}
private drawText(text: string, textOptions?: ITextOptions): number {
let indexStart = 0;
const lines: string[] = [];
for (let indexEnd = 1; indexEnd <= text.length; indexEnd++) {
const str = text.slice(indexStart, indexEnd);
const measureText = this.ctx?.measureText(str);
// 换行
if (measureText?.width! > textOptions?.width!) {
lines.push(text.slice(indexStart, indexEnd - 1));
indexStart = indexEnd - 1;
}
// 最后一个字符
if (indexEnd === text.length) {
lines.push(text.slice(indexStart));
}
}
let top = textOptions?.top;
lines.forEach((line, index) => {
top = textOptions?.top! + index * (this.options.fontSize! + 10);
this.ctx?.fillText(line, textOptions?.left!, top);
});
const height = top! - textOptions?.top! + this.options.fontSize!;
return height;
}
private drawBackground(backgroundColor?: string) {
// background
this.ctx!.fillStyle =
backgroundColor || this.options.backgroundColor || "#FFF";
this.ctx!.fillRect(0, 0, this.canvas.width, this.canvas.height);
// recover font
this.ctx!.font = `${this.options.fontSize}px ${this.options.fontFamily}`;
this.ctx!.fillStyle = `${this.options.fontColor}`;
}
}
worker.js
/* eslint-disable @typescript-eslint/naming-convention */
import {TinyCanvas} from './tiny-util.umd.cjs'
const CanvasCache = new Map()
onmessage = (event) => {
const {sessionId, tasks} = event.data
tasks.forEach((e) => {
const placeholder = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
const {caption, formCfg, columns = 1} = placeholder
let canvasInstance = CanvasCache.get(columns)
if (!canvasInstance) {
const tinyCanvasOptions = {
isOffscreen: true,
width: Math.floor(635 / columns),
height: Math.floor(152 - (columns - 1) * 10),
fontSize: 16 - (columns - 1) * 2,
backgroundColor: '#F4F4F5',
fontColor: '#409EFF',
fontFamily: 'serif',
}
canvasInstance = new TinyCanvas(tinyCanvasOptions)
CanvasCache.set(columns, canvasInstance)
}
canvasInstance.resetHeight()
canvasInstance
.renderForm(caption, typeof formCfg === 'string' ? JSON.parse(formCfg) : formCfg)
.then((url) => {
self.postMessage({
sessionId,
id: e.id,
url: url,
extra: e.extra
})
})
})
}