e-virt-table
Version:
A powerful data table based on canvas. You can use it as data grid、Microsoft Excel or Google sheets. It supports virtual scroll、cell edit etc.
562 lines • 21.6 kB
JavaScript
export class Paint {
constructor(target) {
Object.defineProperty(this, "ctx", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "textCacheMap", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
const ctx = target.getContext('2d');
if (!ctx)
throw new Error('canvas context not found');
this.ctx = ctx;
}
getCtx() {
return this.ctx;
}
clearTextCache() {
this.textCacheMap.clear();
}
scale(dpr, zoom = 1) {
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
// dpr 保证清晰度,zoom 实现内容缩放,两者相乘按原生分辨率绘制,不会模糊
this.ctx.scale(dpr * zoom, dpr * zoom);
}
save() {
this.ctx.save();
}
restore() {
this.ctx.restore();
}
translate(x, y) {
this.ctx.translate(x, y);
}
setCursor(cursor = 'default') {
this.ctx.canvas.style.cursor = cursor;
}
clear(x = 0, y = 0, width, height) {
this.ctx.clearRect(x, y, width || this.ctx.canvas.width, height || this.ctx.canvas.height);
}
/**
* 在 Canvas 上绘制单侧阴影
* @param {number} x - 矩形的 x 坐标
* @param {number} y - 矩形的 y 坐标
* @param {number} width - 矩形的宽度
* @param {number} height - 矩形的高度
* @param {string} side - 阴影的位置('left', 'right', 'top', 'bottom')
* @param {number} shadowWidth - 阴影的宽度
* @param {string} color - 阴影的颜色
*/
drawShadow(x, y, width, height, options) {
const { fillColor, side, shadowWidth, colorStart, colorEnd } = options;
this.ctx.save();
if (fillColor) {
this.ctx.fillStyle = fillColor;
this.ctx.fillRect(x, y, width, height);
}
let gradient;
switch (side) {
case 'left':
gradient = this.ctx.createLinearGradient(x - shadowWidth, y, x, y);
gradient.addColorStop(0, colorStart);
gradient.addColorStop(1, colorEnd);
this.ctx.fillStyle = gradient;
this.ctx.fillRect(x - shadowWidth, y, shadowWidth, height);
break;
case 'right':
gradient = this.ctx.createLinearGradient(x + width, y, x + width + shadowWidth, y);
gradient.addColorStop(0, colorStart);
gradient.addColorStop(1, colorEnd);
this.ctx.fillStyle = gradient;
this.ctx.fillRect(x + width, y, shadowWidth, height);
break;
case 'top':
gradient = this.ctx.createLinearGradient(x, y - shadowWidth, x, y);
gradient.addColorStop(0, colorStart);
gradient.addColorStop(1, colorEnd);
this.ctx.fillStyle = gradient;
this.ctx.fillRect(x, y - shadowWidth, width, shadowWidth);
break;
case 'bottom':
gradient = this.ctx.createLinearGradient(x, y + height, x, y + height + shadowWidth);
gradient.addColorStop(0, colorStart);
gradient.addColorStop(1, colorEnd);
this.ctx.fillStyle = gradient;
this.ctx.fillRect(x, y + height, width, shadowWidth);
break;
default:
console.error('Invalid side specified for shadow');
break;
}
this.ctx.restore();
}
// 绘制线条
drawLine(points, options) {
if (points.length < 4 || points.length % 2 !== 0) {
throw new Error('A valid array of points is required to draw a line');
}
this.ctx.save();
const { borderColor = 'black', borderWidth = 1 } = options;
this.ctx.beginPath();
this.ctx.moveTo(points[0] - 0.5, points[1] - 0.5);
for (let i = 2; i < points.length; i += 2) {
this.ctx.lineTo(points[i] - 0.5, points[i + 1] - 0.5);
}
this.ctx.strokeStyle = borderColor;
this.ctx.lineWidth = borderWidth;
if (options.lineDash) {
this.ctx.lineDashOffset = options.lineDashOffset ?? 0;
this.ctx.setLineDash(options.lineDash);
}
if (options.fillColor) {
this.ctx.fillStyle = options.fillColor;
this.ctx.fill();
}
if (options.borderColor) {
this.ctx.strokeStyle = options.borderColor;
}
this.ctx.stroke();
this.ctx.closePath();
this.ctx.restore();
}
drawImage(img, x, y, width, height) {
this.ctx.save();
this.ctx.drawImage(img, x, y, width, height);
this.ctx.restore();
}
drawRect(x, y, width, height, { borderWidth = 1, borderColor, fillColor, radius = 0 } = {}) {
this.ctx.save();
this.ctx.beginPath();
// 填充颜色
if (fillColor !== undefined) {
this.ctx.fillStyle = fillColor;
}
// 线条宽度及绘制颜色
if (borderColor !== undefined) {
this.ctx.lineWidth = borderWidth;
this.ctx.strokeStyle = borderColor;
}
if (radius === 0) {
// 绘制矩形路径,- 0.5解决1px模糊的问题
this.ctx.rect(x - 0.5, y - 0.5, width, height);
}
else {
// 确保 radius 是一个包含四个元素的数组
const [tl, tr, br, bl] = typeof radius === 'number' ? [radius, radius, radius, radius] : radius;
// 绘制圆角矩形路径
this.ctx.moveTo(x + tl, y);
this.ctx.arcTo(x + width, y, x + width, y + tr, tr); // draw right side and top-right corner
this.ctx.arcTo(x + width, y + height, x + width - br, y + height, br); // draw bottom side and bottom-right corner
this.ctx.arcTo(x, y + height, x, y + height - bl, bl); // draw left side and bottom-left corner
this.ctx.arcTo(x, y, x + tl, y, tl); // draw top side and top-left corner
}
// 如果有填充色,则填充
if (fillColor !== undefined) {
this.ctx.fill();
}
// 如果有绘制色,则绘制
if (borderColor !== undefined) {
this.ctx.stroke();
}
this.ctx.restore();
}
/**
* 画文本
* @param text
* @param x
* @param y
* @param width
* @param height
* @param options
* @returns 是否溢出
*/
drawText(text = '', x, y, width, height, options = {}) {
this.ctx.save();
const { font = '12px Arial', align = 'center', color = '#495060', padding = 0, verticalAlign = 'middle', maxLineClamp = 1, autoRowHeight = false, offsetLeft = 0, offsetRight = 0, } = options;
this.ctx.font = font;
this.ctx.fillStyle = color;
if (['', null, undefined].includes(text)) {
this.ctx.restore();
return false;
}
const fontSize = parseInt(font.match(/\d+/)?.[0] || '12');
const lineHeight = fontSize * (options.lineHeight || 1.2);
const layout = this.buildTextLayout(text, x, y, width, height, {
font,
color,
align,
padding,
verticalAlign,
maxLineClamp,
autoRowHeight,
lineHeight,
offsetLeft,
offsetRight,
});
this.ctx.textBaseline = 'top';
this.ctx.textAlign = 'left';
layout.lines.forEach((line) => {
this.ctx.fillText(line.text, line.drawX, line.drawY);
});
if (options.textCallback && layout.lines.length) {
const maxLineWidth = layout.lines.reduce((max, line) => Math.max(max, line.width), 0);
const textMaxWidth = Math.round(maxLineWidth);
let left = layout.lines[0]?.drawX ?? x + padding + offsetLeft;
let right = left + textMaxWidth;
if (align === 'center') {
left = x + width / 2 - textMaxWidth / 2;
right = x + width / 2 + textMaxWidth / 2;
}
else if (align === 'right') {
left = x + width - padding - offsetRight - textMaxWidth;
right = x + width - padding - offsetRight;
}
const textInfo = {
x: layout.lines[0]?.drawX ?? left,
y: layout.contentY,
width: textMaxWidth,
height: layout.lines.length * lineHeight,
left,
right,
top: layout.contentY,
bottom: layout.contentY + layout.lines.length * lineHeight,
};
options.textCallback(textInfo);
}
if (options.layoutCallback) {
options.layoutCallback(layout);
}
this.ctx.restore();
return layout.ellipsis;
}
buildTextLayout(text, x, y, width, height, options) {
const { font, color, align, padding, verticalAlign, maxLineClamp, autoRowHeight, lineHeight, offsetLeft, offsetRight, } = options;
this.ctx.save();
this.ctx.font = font;
const availableWidth = width - padding * 2 - offsetLeft - offsetRight;
const contentWidth = availableWidth;
const contentHeight = height - padding * 2;
const chars = Array.from(text);
let lines = this.wrapTextToLines(text, availableWidth);
const maxTextLine = Math.round(contentHeight / lineHeight);
let totalTextLine = Math.min(lines.length, Math.max(maxTextLine, 1));
if (maxLineClamp === 'auto' && autoRowHeight) {
totalTextLine = lines.length;
}
else if (typeof maxLineClamp === 'number' && maxLineClamp < totalTextLine && maxLineClamp !== 1) {
totalTextLine = maxLineClamp;
}
else {
if (maxLineClamp === 1) {
lines = [{ items: chars.map((char, i) => ({ char, sourceIndex: i })), text, caretIndex: 0, lineIndex: 0, drawX: 0, drawY: 0, width: 0, segments: [] }];
totalTextLine = 1;
}
if (maxLineClamp === 'auto' && maxTextLine === 1) {
lines = [{ items: chars.map((char, i) => ({ char, sourceIndex: i })), text, caretIndex: 0, lineIndex: 0, drawX: 0, drawY: 0, width: 0, segments: [] }];
totalTextLine = 1;
}
}
let ellipsis = false;
const visibleLines = lines.slice(0, totalTextLine);
if (lines.length > totalTextLine) {
ellipsis = true;
const remainingLines = lines.slice(totalTextLine - 1);
const ellipsisLine = this.buildEllipsisLine(remainingLines, width, padding);
ellipsisLine.lineIndex = totalTextLine - 1;
visibleLines[totalTextLine - 1] = ellipsisLine;
}
else if (totalTextLine > 0) {
const lastLineIndex = totalTextLine - 1;
const remainingLines = lines.slice(lastLineIndex);
const remainingText = remainingLines.map((line) => line.text).join('');
const { ellipsis: widthEllipsis } = this.handleEllipsis(remainingText, width, padding, font);
if (widthEllipsis) {
ellipsis = true;
const ellipsisLine = this.buildEllipsisLine(remainingLines, width, padding);
ellipsisLine.lineIndex = lastLineIndex;
visibleLines[lastLineIndex] = ellipsisLine;
}
}
const totalTextHeight = visibleLines.length * lineHeight;
let contentY = y + padding;
if (verticalAlign === 'middle') {
contentY = y + (height - totalTextHeight) / 2;
}
else if (verticalAlign === 'bottom') {
contentY = y + height - totalTextHeight - padding;
}
const glyphs = [];
visibleLines.forEach((line, index) => {
line.lineIndex = index;
const segments = this.buildLineSegments(line);
const lineWidth = segments.reduce((sum, segment) => sum + segment.width, 0);
let lineX = x + padding + offsetLeft;
if (align === 'center') {
lineX = x + (width - lineWidth) / 2;
}
else if (align === 'right') {
lineX = x + width - padding - offsetRight - lineWidth;
}
const lineY = contentY + index * lineHeight;
line.drawX = lineX;
line.drawY = lineY;
line.width = lineWidth;
line.segments = segments;
segments.forEach((segment) => {
if (segment.sourceIndex < 0) {
return;
}
glyphs.push({
char: segment.char,
index: segment.sourceIndex,
line: index,
x: lineX + segment.x,
y: lineY,
width: segment.width,
height: lineHeight,
});
});
});
this.ctx.restore();
return {
text,
chars,
glyphs,
lines: visibleLines,
box: { x, y, width, height },
contentBox: { x: x + padding + offsetLeft, y: y + padding, width: contentWidth, height: contentHeight },
contentY,
lineHeight,
font,
color,
align,
ellipsis,
};
}
wrapTextToLines(text, maxWidth) {
if (!text) {
return [{ items: [], text: '', caretIndex: 0, lineIndex: 0, drawX: 0, drawY: 0, width: 0, segments: [] }];
}
const chars = Array.from(text);
const lines = [];
let currentItems = [];
let currentWidth = 0;
let lineCaretIndex = 0;
const flushLine = (nextCaretIndex) => {
const lineText = currentItems.map((item) => item.char).join('');
lines.push({
items: currentItems,
text: lineText,
caretIndex: lineCaretIndex,
lineIndex: lines.length,
drawX: 0,
drawY: 0,
width: 0,
segments: [],
});
currentItems = [];
currentWidth = 0;
lineCaretIndex = nextCaretIndex;
};
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
if (char === '\n') {
flushLine(i + 1);
continue;
}
const charWidth = this.ctx.measureText(char).width;
if (currentWidth + charWidth > maxWidth && currentItems.length > 0) {
flushLine(currentItems[currentItems.length - 1].sourceIndex + 1);
currentItems.push({ char, sourceIndex: i });
currentWidth = charWidth;
continue;
}
currentItems.push({ char, sourceIndex: i });
currentWidth += charWidth;
}
if (currentItems.length > 0 || lines.length === 0) {
flushLine(chars.length);
}
return lines;
}
buildEllipsisLine(remainingLines, width, padding) {
const remainingItems = remainingLines.flatMap((line) => line.items);
const remainingText = remainingItems.map((item) => item.char).join('');
const { _text, ellipsis } = this.handleEllipsis(remainingText, width, padding, this.ctx.font);
const contentText = ellipsis ? _text.slice(0, -3) : _text;
const items = [];
for (let i = 0; i < contentText.length; i++) {
const item = remainingItems[i];
if (!item) {
break;
}
items.push({ char: item.char, sourceIndex: item.sourceIndex });
}
if (ellipsis) {
items.push({ char: '.', sourceIndex: -1, ellipsis: true }, { char: '.', sourceIndex: -1, ellipsis: true }, { char: '.', sourceIndex: -1, ellipsis: true });
}
return {
items,
text: _text,
caretIndex: remainingLines[0]?.caretIndex ?? 0,
lineIndex: 0,
drawX: 0,
drawY: 0,
width: 0,
segments: [],
};
}
buildLineSegments(line) {
const segments = [];
let offsetX = 0;
line.items.forEach((item, itemIndex) => {
const segmentWidth = this.ctx.measureText(item.char).width;
segments.push({
char: item.char,
itemIndex,
sourceIndex: item.sourceIndex,
x: offsetX,
width: segmentWidth,
});
offsetX += segmentWidth;
});
return segments;
}
/**
* 将文本按宽度换行
* @param text
* @param maxWidth
* @returns
*/
wrapText(text, maxWidth, cacheTextKey = '') {
if (!text)
return [''];
// 缓存文本
if (cacheTextKey && this.textCacheMap.has(cacheTextKey)) {
return this.textCacheMap.get(cacheTextKey) || [''];
}
const lines = [];
const paragraphs = text.split('\n');
for (const paragraph of paragraphs) {
if (paragraph === '') {
lines.push('');
continue;
}
const words = paragraph.split('');
let currentLine = '';
for (const word of words) {
const testLine = currentLine + word;
const testWidth = this.ctx.measureText(testLine).width;
if (testWidth <= maxWidth) {
currentLine = testLine;
}
else {
if (currentLine) {
lines.push(currentLine);
currentLine = word;
}
else {
// 单个字符也超出宽度,强制换行
lines.push(word);
currentLine = '';
}
}
}
if (currentLine) {
lines.push(currentLine);
}
}
const result = lines.length > 0 ? lines : [''];
if (cacheTextKey) {
this.textCacheMap.set(cacheTextKey, result);
}
return result;
}
/**
* 计算文本自适应高度
* @param text
* @param width
* @param options
* @returns 计算出的高度
*/
calculateTextHeight(text = '', width, options = {}) {
const { font = '12px Arial', padding = 0, align = 'center', color = '#495060', maxLineClamp = 1, cacheTextKey = '', } = options;
this.ctx.save();
this.ctx.font = font;
this.ctx.fillStyle = color;
this.ctx.textAlign = align;
// // 获取字体高度
const fontSize = parseInt(font.match(/\d+/)?.[0] || '12');
const lineHeight = fontSize * (options.lineHeight || 1.2); // 默认行高为字体大小的1.2倍
// 将文本按可用宽度分割成行
const availableWidth = width - padding * 2;
const lines = this.wrapText(text, availableWidth, cacheTextKey);
// 计算总行数
let totalLines = 1;
if (maxLineClamp === 'auto') {
totalLines = lines.length;
}
else {
if (lines.length > maxLineClamp) {
totalLines = maxLineClamp;
}
else {
totalLines = lines.length;
}
}
this.ctx.restore();
// 计算总高度:行数 * 行高 + 上下padding
return Math.max(Math.floor(totalLines * lineHeight + padding * 2), Math.floor(fontSize + padding * 2));
}
handleEllipsis(text, width, padding = 0, font = '12px Arial') {
this.ctx.save();
let ellipsis = false;
let _text = text;
this.ctx.font = font;
if (text === null || text === undefined || text === '') {
this.ctx.restore();
return {
_text: '',
ellipsis,
};
}
const ellipsesWidth = this.ctx.measureText('...').width;
// 如果宽度小于省略号宽度,则不进行省略,直接返回空字符串
if (width <= ellipsesWidth + padding * 2) {
this.ctx.restore();
return {
_text: '',
ellipsis: true,
};
}
const textWidth = this.ctx.measureText(text).width;
const lineWidth = width - padding * 2;
// 单行文本省略
if (textWidth && textWidth >= lineWidth) {
ellipsis = true;
// text字符截取并添加省略号
let textLength = 0;
for (let i = 0; i < text.length; i++) {
textLength += this.ctx.measureText(text[i]).width;
if (textLength >= lineWidth - ellipsesWidth) {
_text = text.slice(0, i) + '...';
ellipsis = true;
break;
}
}
}
this.ctx.restore();
return {
_text,
ellipsis,
};
}
}
export default Paint;
//# sourceMappingURL=Paint.js.map