@antv/s2
Version:
effective spreadsheet render core lib
360 lines • 14.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GuiIcon = void 0;
/**
* @description: 请严格要求 svg 的 viewBox,若设计产出的 svg 不是此规格,请叫其修改为 '0 0 1024 1024'
*/
const g_1 = require("@antv/g");
const lodash_1 = require("lodash");
const engine_1 = require("../../engine");
const g_utils_1 = require("../../utils/g-utils");
const debug_1 = require("../debug");
const factory_1 = require("./factory");
const STYLE_PLACEHOLDER = '<svg';
const SVG_CONTENT_TYPE = 'data:image/svg+xml';
// Image 缓存
const ImageCache = {};
const PathDataCache = {};
/**
* 从 SVG 字符串中解析 viewBox 和 path 数据
* @see https://github.com/antvis/S2/issues/3125
*/
function parseSvgPaths(svg) {
// 如果 SVG 包含 transform, g, rect 等不支持的复杂标签或属性,暂不使用 Path 模式
// 避免渲染错位
if (/transform|translate|scale|rotate|<g|<rect|<circle|<ellipse|<line|<polyline|<polygon|<text|<tspan/i.test(svg)) {
return null;
}
// 提取 viewBox
const viewBoxMatch = svg.match(/viewBox=["']([^"']+)["']/);
if (!viewBoxMatch) {
return null;
}
const viewBoxParts = viewBoxMatch[1].split(/\s+/).map(Number);
if (viewBoxParts.length < 4) {
return null;
}
const viewBox = {
width: viewBoxParts[2],
height: viewBoxParts[3],
};
// 提取所有 path 的 d 属性
// 使用 \b 确保匹配 d= 而不是 p-id= 等其他属性
// 先将换行符替换为空格,使正则匹配更简单
const normalizedSvg = svg.replace(/[\r\n]+/g, ' ');
let paths = [];
// 匹配双引号形式 d="..."
const doubleQuoteMatches = normalizedSvg.match(/\bd="[^"]+"/g);
if (doubleQuoteMatches) {
paths = doubleQuoteMatches.map((m) => m.slice(3, -1));
}
// 如果双引号没有匹配到,尝试单引号形式 d='...'
if (paths.length === 0) {
const singleQuoteMatches = normalizedSvg.match(/\bd='[^']+'/g);
if (singleQuoteMatches) {
paths = singleQuoteMatches.map((m) => m.slice(3, -1));
}
}
if (paths.length === 0) {
return null;
}
return { viewBox, paths };
}
/**
* 使用 iconfont 上的 svg 来创建 Icon
* 支持两种渲染模式:
* 1. Path 模式 (CSP 友好): 直接使用 G.Path 绘制矢量图形
* 2. Image 模式 (兼容): 使用 Blob URL 加载 SVG 图片
*/
class GuiIcon extends g_1.Group {
constructor(cfg) {
super({ name: cfg.name });
// icon 对应的 Path 和 HitArea 对象数组 (Path 模式)
// 第一个元素是透明的点击热区 Rect,其余是实际的 Path
this.iconPathShapes = [];
// 是否使用 Path 模式渲染
this.usePathMode = false;
/**
* 1. https://xxx.svg
* 2. http://xxx.svg
* 3. //xxx.svg
*/
this.isOnlineLink = (src) => /^(?:https?:)?(?:\/\/)/.test(src);
this.cfg = cfg;
this.render();
}
getCfg() {
return this.cfg;
}
/**
* 尝试使用 Path 模式渲染图标 (CSP 完全兼容)
* @returns 是否成功使用 Path 模式
*/
tryRenderAsPath(name, fill) {
const svg = (0, factory_1.getIcon)(name);
if (!svg) {
return false;
}
// 如果是在线链接或 base64,无法使用 Path 模式
if (svg.includes(SVG_CONTENT_TYPE) || this.isOnlineLink(svg)) {
return false;
}
const cacheKey = name;
let parsedData = PathDataCache[cacheKey];
if (!parsedData) {
const parsed = parseSvgPaths(svg);
if (parsed) {
parsedData = parsed;
PathDataCache[cacheKey] = parsed;
}
}
if (!parsedData) {
return false;
}
const { x = 0, y = 0, width, height, cursor } = this.cfg;
// 计算缩放比例 (添加类型守卫以防 width/height 不是数字)
const numWidth = typeof width === 'number' ? width : 0;
const numHeight = typeof height === 'number' ? height : 0;
const scaleX = numWidth / parsedData.viewBox.width;
const scaleY = numHeight / parsedData.viewBox.height;
// 清除旧的 path shapes
this.iconPathShapes.forEach((shape) => {
this.removeChild(shape);
shape.destroy();
});
this.iconPathShapes = [];
// 创建透明的矩形作为点击热区,确保整个图标区域都可以点击
// 同时设置 cursor 样式
const hitAreaRect = new g_1.Rect({
style: {
x: typeof x === 'number' ? x : 0,
y: typeof y === 'number' ? y : 0,
width: numWidth,
height: numHeight,
fill: 'transparent',
cursor: cursor || 'default',
},
});
this.appendChild(hitAreaRect);
this.iconPathShapes.push(hitAreaRect);
// 创建所有 path
for (const pathData of parsedData.paths) {
const pathShape = new g_1.Path({
style: {
d: pathData,
fill: fill || '#000',
transformOrigin: '0 0',
transform: `translate(${x}, ${y}) scale(${scaleX}, ${scaleY})`,
cursor: cursor || 'default',
// 禁用 path 的事件,让事件传递到底层的 hitAreaRect
pointerEvents: 'none',
},
});
this.appendChild(pathShape);
this.iconPathShapes.push(pathShape);
}
return true;
}
// 获取 Image 实例,使用缓存,以避免滚动时因重复的 new Image() 耗时导致的闪烁问题
/* 异步获取 image 实例 */
getImage(name, cacheKey, fill) {
return new Promise((resolve, reject) => {
let svg = (0, factory_1.getIcon)(name);
if (!svg) {
return;
}
const img = new Image();
img.onload = () => {
ImageCache[cacheKey] = img;
resolve(img);
};
img.onerror = reject;
/*
* 兼容三种情况
* 1、base 64
* 2、svg本地文件(兼容老方式,可以改颜色)
* 3、线上支持的图片地址
*/
if (svg && (svg.includes(SVG_CONTENT_TYPE) || this.isOnlineLink(svg))) {
/*
* 传入 base64 字符串
* 或者 online 链接
*/
img.src = svg;
// https://github.com/antvis/S2/issues/2513
img.crossOrigin = 'anonymous';
}
else {
// 传入 svg 字符串(支持颜色fill)
if (fill) {
/*
* 如果有fill,移除原来的 fill
* 这里有一个潜在的问题,不同的svg里面的schema不尽相同,导致这个正则考虑不全
* 1、fill='' 2、fill 3、fill-***(不需要处理)
*/
// 移除 fill="red|#fff"
// eslint-disable-next-line no-useless-escape
svg = svg.replace(/fill=[\'\"]\#?\w+[\'\"]/g, '');
// fill> 替换为 >
svg = svg.replace(/fill>/g, '>');
}
svg = svg.replace(STYLE_PLACEHOLDER, `${STYLE_PLACEHOLDER} fill="${fill}"`);
/**
* 使用 Blob URL 替代 data: URL 以兼容严格的 CSP 策略
* @see https://github.com/antvis/S2/issues/3125
* 兼容 Firefox: https://github.com/antvis/S2/issues/1571
*/
const blob = new Blob([svg], { type: 'image/svg+xml' });
const blobUrl = URL.createObjectURL(blob);
// 加载完成后释放 Blob URL 以防止内存泄漏
const originalOnload = img.onload;
img.onload = (event) => {
URL.revokeObjectURL(blobUrl);
originalOnload === null || originalOnload === void 0 ? void 0 : originalOnload.call(img, event);
};
const originalOnerror = img.onerror;
img.onerror = (event) => {
URL.revokeObjectURL(blobUrl);
if (typeof originalOnerror === 'function') {
originalOnerror.call(img, event);
}
};
img.src = blobUrl;
}
});
}
render() {
const { name, fill, iconStrategy } = this.cfg;
// 如果指定了 path 模式,优先尝试 Path 模式 (完全绕过 CSP 限制)
if (iconStrategy === 'path' && this.tryRenderAsPath(name, fill)) {
this.usePathMode = true;
return;
}
// 默认或失败时回退到 Image 模式
this.usePathMode = false;
const attrs = (0, lodash_1.clone)(this.cfg);
const image = new engine_1.CustomImage(GuiIcon.type, {
style: (0, lodash_1.omit)(attrs, ['fill', 'iconStrategy']),
});
this.iconImageShape = image;
this.setImageAttrs({ name, fill });
}
reRender(cfg) {
this.name = cfg.name;
this.cfg = cfg;
const { name, fill, iconStrategy } = this.cfg;
// 清除旧的渲染
if (this.usePathMode) {
this.iconPathShapes.forEach((shape) => {
this.removeChild(shape);
shape.destroy();
});
this.iconPathShapes = [];
}
// 如果指定了 path 模式,优先尝试 Path 模式
if (iconStrategy === 'path' && this.tryRenderAsPath(name, fill)) {
this.usePathMode = true;
return;
}
// 回退到 Image 模式
this.usePathMode = false;
const attrs = (0, lodash_1.clone)(this.cfg);
if (!this.iconImageShape) {
this.iconImageShape = new engine_1.CustomImage(GuiIcon.type, {
style: (0, lodash_1.omit)(attrs, ['fill', 'iconStrategy']),
});
}
else {
this.iconImageShape.imgType = GuiIcon.type;
(0, g_utils_1.batchSetStyle)(this.iconImageShape, (0, lodash_1.omit)(attrs, ['fill', 'iconStrategy']));
}
this.setImageAttrs({ name, fill });
}
updatePosition(position) {
if (this.usePathMode) {
const { width, height } = this.cfg;
const parsedData = PathDataCache[this.cfg.name];
if (parsedData) {
const numWidth = typeof width === 'number' ? width : 0;
const numHeight = typeof height === 'number' ? height : 0;
const scaleX = numWidth / parsedData.viewBox.width;
const scaleY = numHeight / parsedData.viewBox.height;
this.iconPathShapes.forEach((shape) => {
shape.style.transform = `translate(${position.x}, ${position.y}) scale(${scaleX}, ${scaleY})`;
});
}
}
else if (this.iconImageShape) {
(0, g_utils_1.batchSetStyle)(this.iconImageShape, position);
}
}
setImageAttrs(attrs) {
// Path 模式下直接更新 fill
if (this.usePathMode) {
const fill = attrs.fill || this.cfg.fill || '#000';
// 第一个元素是透明热区 (hitAreaRect),应保持透明,从第二个开始更新 fill
this.iconPathShapes.slice(1).forEach((shape) => {
shape.style.fill = fill;
});
return;
}
// Image 模式
let { name, fill } = attrs;
const { iconImageShape: image } = this;
if (!image) {
return;
}
// 保证 name 和 fill 都有值
name = name || this.cfg.name;
fill = fill || this.cfg.fill;
const cacheKey = `${name}-${fill}`;
const imgCache = ImageCache[cacheKey];
if (imgCache) {
// already in cache
image.attr('src', imgCache);
this.appendChild(image);
}
else {
this.getImage(name, cacheKey, fill)
.then((img) => {
// 异步加载完成后,当前 Cell 可能已经销毁了
if (this.destroyed) {
debug_1.DebuggerUtil.getInstance().logger(`GuiIcon ${name} destroyed.`);
return;
}
image.attr('src', img);
this.appendChild(image);
})
.catch((event) => {
// 如果是 TypeError, 则是 G 底层渲染有问题, 其他场景才报加载异常的错误
if (event instanceof TypeError) {
// eslint-disable-next-line no-console
console.warn(`GuiIcon ${name} destroyed:`, event);
return;
}
// eslint-disable-next-line no-console
console.error(`GuiIcon ${name} load failed:`, event);
});
}
}
/**
* https://github.com/antvis/S2/issues/2772
* G 6.0 如果是多图层, 需要手动全部隐藏, 直接隐藏父容器 Group 还不行, 或者使用 icon.show()
* https://github.com/antvis/G/blob/277abff24936ef6f7c43407a16c5bc9260992511/packages/g-lite/src/display-objects/DisplayObject.ts#L853
*/
toggleVisibility(visible) {
const status = visible ? 'visible' : 'hidden';
this.setAttribute('visibility', status);
if (this.usePathMode) {
this.iconPathShapes.forEach((shape) => {
shape.style.visibility = status;
});
}
else if (this.iconImageShape) {
this.iconImageShape.setAttribute('visibility', status);
}
}
}
exports.GuiIcon = GuiIcon;
GuiIcon.type = '__GUI_ICON__';
//# sourceMappingURL=gui-icon.js.map