From 2bdeb575e83523db07f73742b2fa9389c56f8d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B2=90=E7=9B=AE?= Date: Tue, 28 Jul 2026 19:46:54 +0800 Subject: [PATCH] fix(echarts): enforce tooltip safety invariants --- .changeset/safe-tooltips-stay-contained.md | 6 +++ packages/echarts/src/index.ts | 57 ++++++++++++++++------ packages/echarts/test/echarts.test.ts | 39 +++++++++++++++ 3 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 .changeset/safe-tooltips-stay-contained.md diff --git a/.changeset/safe-tooltips-stay-contained.md b/.changeset/safe-tooltips-stay-contained.md new file mode 100644 index 0000000..71dd85b --- /dev/null +++ b/.changeset/safe-tooltips-stay-contained.md @@ -0,0 +1,6 @@ +--- +"@datafe-open/markdown-chart-echarts": patch +--- + +Force ECharts tooltip containment and rich-text rendering safety settings after +applying model-provided chart options. diff --git a/packages/echarts/src/index.ts b/packages/echarts/src/index.ts index 893ae75..b135a05 100644 --- a/packages/echarts/src/index.ts +++ b/packages/echarts/src/index.ts @@ -673,6 +673,19 @@ function styleComponent( return value === undefined || value === null ? cloneJson(defaults) : value; } +function forceTooltipSafety(value: JsonValue): JsonValue { + const force = (entry: JsonValue): JsonValue => isJsonObject(entry) + ? { + ...entry, + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + } + : entry; + return Array.isArray(value) ? value.map(force) : force(value); +} + function hasOwnPosition( value: Record, position: 'top' | 'bottom', @@ -818,7 +831,16 @@ function styleSeriesEntry( itemStyle: { borderColor: tokens.seriesBorder, borderWidth: 2 }, }); } - return mergeObjectDefaults(defaults, series); + const styled = mergeObjectDefaults(defaults, series); + if (Object.prototype.hasOwnProperty.call(series, 'tooltip')) { + styled.tooltip = forceTooltipSafety(styleComponent(series.tooltip, { + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + })); + } + return styled; } const ITEM_ONLY_SERIES_TYPES = new Set(['pie', 'funnel', 'gauge', 'radar', 'treemap']); @@ -889,21 +911,24 @@ export function applyEChartsDefaultStyle( subtextStyle: { color: tokens.subtext }, }); } - styled.tooltip = styleComponent(styled.tooltip, { - trigger: defaultTooltipTrigger(option), - confine: true, - enterable: false, - renderMode: 'richText', - backgroundColor: tokens.tooltipBg, - borderColor: tokens.splitLine, - borderWidth: 1, - padding: [8, 10], - textStyle: { color: tokens.titleText, fontSize: 12 }, - axisPointer: { - lineStyle: { color: tokens.pointer, width: 1 }, - crossStyle: { color: tokens.pointer, width: 1 }, - }, - }); + styled.tooltip = forceTooltipSafety( + styleComponent(styled.tooltip, { + trigger: defaultTooltipTrigger(option), + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + backgroundColor: tokens.tooltipBg, + borderColor: tokens.splitLine, + borderWidth: 1, + padding: [8, 10], + textStyle: { color: tokens.titleText, fontSize: 12 }, + axisPointer: { + lineStyle: { color: tokens.pointer, width: 1 }, + crossStyle: { color: tokens.pointer, width: 1 }, + }, + }), + ); if (shouldApplyDefaultLegend(option)) { styled.legend = styleVerticallyPositionedComponent(styled.legend, { type: 'scroll', diff --git a/packages/echarts/test/echarts.test.ts b/packages/echarts/test/echarts.test.ts index 5d10f0f..368880f 100644 --- a/packages/echarts/test/echarts.test.ts +++ b/packages/echarts/test/echarts.test.ts @@ -489,6 +489,45 @@ describe('createEChartsRenderer', () => { }); }); + it('forces tooltip safety invariants after applying explicit values', () => { + const styled = applyEChartsDefaultStyle({ + tooltip: { + appendToBody: true, + confine: false, + enterable: true, + renderMode: 'html', + trigger: 'item', + }, + series: [{ + type: 'bar', + tooltip: { + appendToBody: true, + confine: false, + enterable: true, + renderMode: 'html', + trigger: 'axis', + }, + }], + }); + + expect(styled.tooltip).toMatchObject({ + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + trigger: 'item', + }); + expect(styled.series).toMatchObject([{ + tooltip: { + appendToBody: false, + confine: true, + enterable: false, + renderMode: 'richText', + trigger: 'axis', + }, + }]); + }); + it.each(['pie', 'funnel'] as const)( 'keeps the default item legend for a single %s series', (type) => {