Skip to content

Commit 0fba0c3

Browse files
authored
fix: implement CSP-compatible SVG icon rendering using Path mode (#3319)
* fix: implement CSP-compatible SVG icon rendering using Path mode * refactor: 修复 CR 意见 * test: 修复单测问题
1 parent 81e36ec commit 0fba0c3

File tree

4 files changed

+442
-20
lines changed

4 files changed

+442
-20
lines changed

packages/s2-core/__tests__/spreadsheet/header-action-icons-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ describe('HeaderActionIcons Tests', () => {
195195
const positions = s2.facet.getRowCells().map((cell) => {
196196
return cell
197197
.getActionIcons()
198-
.map((icon) => pick(icon.iconImageShape.attributes, ['x', 'y']));
198+
.map((icon) => pick(icon.getCfg(), ['x', 'y']));
199199
});
200200

201201
expect(positions).toMatchSnapshot();

packages/s2-core/__tests__/unit/common/icons/gui-icon-spec.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,174 @@ describe('GuiIcon Tests', () => {
140140

141141
expect(render).not.toThrow();
142142
});
143+
144+
// https://github.com/antvis/S2/issues/3125
145+
describe('CSP-compatible Path mode rendering', () => {
146+
test('should render built-in SVG icons using Path mode', () => {
147+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
148+
149+
const icon = new GuiIcon({
150+
name: 'Plus',
151+
x: 10,
152+
y: 10,
153+
width: 16,
154+
height: 16,
155+
});
156+
157+
// Path 模式下应该有 iconPathShapes
158+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
159+
// Path 模式下不应该使用 Image
160+
expect(icon.iconImageShape).toBeUndefined();
161+
expect(icon).toBeInstanceOf(Group);
162+
expect(errSpy).not.toHaveBeenCalled();
163+
164+
errSpy.mockRestore();
165+
});
166+
167+
test('should render Minus icon using Path mode', () => {
168+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
169+
170+
const icon = new GuiIcon({
171+
name: 'Minus',
172+
x: 0,
173+
y: 0,
174+
width: 16,
175+
height: 16,
176+
});
177+
178+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
179+
expect(icon.iconImageShape).toBeUndefined();
180+
expect(errSpy).not.toHaveBeenCalled();
181+
182+
errSpy.mockRestore();
183+
});
184+
185+
test('should apply cursor style to Path mode icons', () => {
186+
const icon = new GuiIcon({
187+
name: 'Plus',
188+
x: 0,
189+
y: 0,
190+
width: 16,
191+
height: 16,
192+
cursor: 'pointer',
193+
});
194+
195+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
196+
197+
// 第一个 shape 是 hitArea Rect,应该有 cursor 样式
198+
const hitAreaRect = icon.iconPathShapes[0];
199+
200+
expect(hitAreaRect.style.cursor).toBe('pointer');
201+
});
202+
203+
test('should render tree icons in tree mode table without errors', async () => {
204+
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
205+
206+
const s2 = createPivotSheet({
207+
width: 400,
208+
height: 300,
209+
hierarchyType: 'tree',
210+
});
211+
212+
await s2.render();
213+
214+
// 验证没有 CSP 或 SVG 解析错误
215+
expect(errSpy).not.toHaveBeenCalled();
216+
217+
// 验证 row cells 存在 (tree mode 应该有展开折叠图标)
218+
const rowCells = s2.facet.getRowCells();
219+
220+
expect(rowCells.length).toBeGreaterThan(0);
221+
222+
s2.destroy();
223+
errSpy.mockRestore();
224+
});
225+
226+
test('should update fill color in Path mode', () => {
227+
const icon = new GuiIcon({
228+
name: 'Plus',
229+
x: 0,
230+
y: 0,
231+
width: 16,
232+
height: 16,
233+
fill: 'red',
234+
});
235+
236+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
237+
238+
// 更新 fill
239+
icon.setImageAttrs({ fill: 'blue' });
240+
241+
// Path shapes 的 fill 应该更新
242+
const pathShape = icon.iconPathShapes[1]; // 第一个是 hitArea
243+
244+
expect(pathShape.style.fill).toBe('blue');
245+
});
246+
247+
test('should reRender icon with new name in Path mode', () => {
248+
const icon = new GuiIcon({
249+
name: 'Plus',
250+
x: 0,
251+
y: 0,
252+
width: 16,
253+
height: 16,
254+
});
255+
256+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
257+
258+
// reRender with new name
259+
icon.reRender({
260+
name: 'Minus',
261+
x: 0,
262+
y: 0,
263+
width: 16,
264+
height: 16,
265+
});
266+
267+
expect(icon.name).toBe('Minus');
268+
expect(icon.iconPathShapes.length).toBeGreaterThan(0);
269+
});
270+
271+
test('should updatePosition correctly in Path mode', () => {
272+
const icon = new GuiIcon({
273+
name: 'Plus',
274+
x: 0,
275+
y: 0,
276+
width: 16,
277+
height: 16,
278+
});
279+
280+
icon.updatePosition({ x: 50, y: 100 });
281+
282+
// 验证 path shapes 的 transform 包含新位置
283+
const pathShape = icon.iconPathShapes[1];
284+
285+
expect(pathShape.style.transform).toContain('50');
286+
expect(pathShape.style.transform).toContain('100');
287+
});
288+
289+
test('should toggle visibility in Path mode', () => {
290+
const icon = new GuiIcon({
291+
name: 'Plus',
292+
x: 0,
293+
y: 0,
294+
width: 16,
295+
height: 16,
296+
});
297+
298+
// 隐藏
299+
icon.toggleVisibility(false);
300+
301+
icon.iconPathShapes.forEach((shape) => {
302+
expect(shape.style.visibility).toBe('hidden');
303+
});
304+
305+
// 显示
306+
icon.toggleVisibility(true);
307+
308+
icon.iconPathShapes.forEach((shape) => {
309+
expect(shape.style.visibility).toBe('visible');
310+
});
311+
});
312+
});
143313
});

packages/s2-core/__tests__/unit/interaction/event-controller-spec.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -929,14 +929,15 @@ describe('Interaction Event Controller Tests', () => {
929929
spreadsheet.container.appendChild(guiIcon); // 加入 g 渲染树才有事件传递
930930
spreadsheet.once(event, handler);
931931

932-
// 内部的 GuiIcon
933-
const { iconImageShape } = guiIcon;
932+
// 内部的 GuiIcon (兼容 Path 模式和 Image 模式)
933+
// Path 模式下使用 iconPathShapes, Image 模式下使用 iconImageShape
934+
const targetShape = guiIcon.iconPathShapes[0] || guiIcon.iconImageShape;
934935

935936
Object.defineProperty(eventController, 'target', {
936-
value: iconImageShape,
937+
value: targetShape,
937938
writable: true,
938939
});
939-
iconImageShape.dispatchEvent(
940+
targetShape.dispatchEvent(
940941
createFederatedPointerEvent(spreadsheet, OriginEventType.POINTER_UP),
941942
);
942943

@@ -1028,14 +1029,14 @@ describe('Interaction Event Controller Tests', () => {
10281029
spreadsheet.container.appendChild(guiIcon); // 加入 g 渲染树才有事件传递
10291030
spreadsheet.once(event, handler);
10301031

1031-
// 内部的 GuiIcon
1032-
const { iconImageShape } = guiIcon;
1032+
// 内部的 GuiIcon (兼容 Path 模式和 Image 模式)
1033+
const targetShape = guiIcon.iconPathShapes[0] || guiIcon.iconImageShape;
10331034

10341035
Object.defineProperty(eventController, 'target', {
1035-
value: iconImageShape,
1036+
value: targetShape,
10361037
writable: true,
10371038
});
1038-
iconImageShape.dispatchEvent(
1039+
targetShape.dispatchEvent(
10391040
createFederatedPointerEvent(spreadsheet, OriginEventType.CLICK),
10401041
);
10411042

0 commit comments

Comments
 (0)