mirror of
https://gitee.com/antv/g6.git
synced 2024-11-29 18:28:19 +08:00
feat: add basic shape (#5377)
* feat(utils): add parsePadding util * feat(utils): add prefix utils * feat: config dev env * refactor(test): update test case context * refactor(test): update layeredCanvas test case * refactor(test): add background grid to help verifying * feat(elements): add label shape * refactor(tests): adjust integration test types definition * test(label): add integraion of label * test(animation): add animation test case
This commit is contained in:
parent
4e11baeb51
commit
4be279b522
@ -36,7 +36,7 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', { allowTemplateLiterals: true, avoidEscape: true }],
|
||||
semi: ['error', 'always'],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }],
|
||||
'jsdoc/require-param-type': 0,
|
||||
'@typescript-eslint/no-this-alias': 'off',
|
||||
|
||||
@ -52,8 +52,6 @@ module.exports = {
|
||||
'jsdoc/require-returns-type': 0,
|
||||
'jsdoc/require-returns-description': 1,
|
||||
|
||||
'@typescript-eslint/no-unused-vars': 1,
|
||||
|
||||
// TODO: rules below are not recommended, and will be removed in the future
|
||||
'@typescript-eslint/no-explicit-any': 1,
|
||||
'@typescript-eslint/ban-types': 1,
|
||||
|
1
packages/g6/__tests__/demo/animation/index.ts
Normal file
1
packages/g6/__tests__/demo/animation/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './label';
|
34
packages/g6/__tests__/demo/animation/label.ts
Normal file
34
packages/g6/__tests__/demo/animation/label.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Label } from '../../../src/elements/shapes/label';
|
||||
import type { AnimationTestCase } from '../types';
|
||||
|
||||
export const labelShape: AnimationTestCase = async (context) => {
|
||||
const { canvas } = context;
|
||||
|
||||
const label = new Label({
|
||||
style: {
|
||||
text: 'label text',
|
||||
fontSize: 20,
|
||||
x: 50,
|
||||
y: 50,
|
||||
stroke: 'pink',
|
||||
backgroundLineWidth: 2,
|
||||
backgroundStroke: 'pink',
|
||||
},
|
||||
});
|
||||
|
||||
await canvas.init();
|
||||
|
||||
canvas.appendChild(label);
|
||||
|
||||
const result = label.animate(
|
||||
[
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 200, y: 200 },
|
||||
],
|
||||
{ duration: 1000, fill: 'both' },
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
labelShape.times = [0, 1000];
|
@ -1 +1,2 @@
|
||||
export * from './canvas';
|
||||
export * from './label';
|
||||
export * from './layered-canvas';
|
||||
|
22
packages/g6/__tests__/demo/static/label.ts
Normal file
22
packages/g6/__tests__/demo/static/label.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Label } from '../../../src/elements/shapes/label';
|
||||
import type { StaticTestCase } from '../types';
|
||||
|
||||
export const labelShape: StaticTestCase = async (context) => {
|
||||
const { canvas } = context;
|
||||
|
||||
const label = new Label({
|
||||
style: {
|
||||
text: 'label text',
|
||||
fontSize: 20,
|
||||
x: 50,
|
||||
y: 50,
|
||||
stroke: 'pink',
|
||||
backgroundLineWidth: 2,
|
||||
backgroundStroke: 'pink',
|
||||
},
|
||||
});
|
||||
|
||||
await canvas.init();
|
||||
|
||||
canvas.appendChild(label);
|
||||
};
|
@ -1,20 +1,8 @@
|
||||
import { Circle, Rect } from '@antv/g';
|
||||
import { Canvas } from '../../../src/runtime/canvas';
|
||||
import type { TestCase } from '../types';
|
||||
import type { StaticTestCase } from '../types';
|
||||
|
||||
export const layeredCanvas: TestCase = async (context) => {
|
||||
const {
|
||||
container,
|
||||
width,
|
||||
height,
|
||||
renderer,
|
||||
canvas = new Canvas({
|
||||
width,
|
||||
height,
|
||||
container,
|
||||
renderer,
|
||||
}),
|
||||
} = context;
|
||||
export const layeredCanvas: StaticTestCase = async (context) => {
|
||||
const { canvas } = context;
|
||||
|
||||
const circle = new Circle({
|
||||
style: {
|
@ -1,19 +1,39 @@
|
||||
import type { IRenderer } from '@antv/g';
|
||||
import type { IAnimation } from '@antv/g';
|
||||
import type { Canvas } from '../../src/runtime/canvas';
|
||||
import type { CanvasLayer } from '../../src/types/canvas';
|
||||
|
||||
type TestCaseContext = {
|
||||
canvas?: Canvas;
|
||||
container: HTMLElement;
|
||||
renderer?: (layer: CanvasLayer) => IRenderer;
|
||||
width: number;
|
||||
height: number;
|
||||
canvas: Canvas;
|
||||
};
|
||||
|
||||
export type TestCase = {
|
||||
export interface StaticTestCase extends BaseTestCase {
|
||||
(context: TestCaseContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AnimationTestCase extends BaseTestCase {
|
||||
(context: TestCaseContext): Promise<IAnimation>;
|
||||
/**
|
||||
* <zh/> 检查的动画时间
|
||||
*
|
||||
* <en/> Animation time to check
|
||||
*/
|
||||
times: number[];
|
||||
}
|
||||
|
||||
export interface BaseTestCase {
|
||||
only?: boolean;
|
||||
skip?: boolean;
|
||||
/**
|
||||
* <zh/> 在测试用例执行前执行的函数
|
||||
*
|
||||
* <en/> Function to be executed before the test case is executed
|
||||
* @returns
|
||||
*/
|
||||
preprocess?: () => Promise<void>;
|
||||
/**
|
||||
* <zh/> 在测试用例执行后执行的函数
|
||||
*
|
||||
* <en/> Function to be executed after the test case is executed
|
||||
* @returns
|
||||
*/
|
||||
postprocess?: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
@ -4,11 +4,28 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>G6: Preview</title>
|
||||
<style>
|
||||
#container {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background-image: linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
|
||||
background-size: 25px 25px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body style="font-family: Arial, Helvetica, sans-serif">
|
||||
<div id="app">
|
||||
<div id="container" style="height: 500px; width: 100%"></div>
|
||||
<label for="demo-select">Cases: </label>
|
||||
<select id="demo-select" style="cursor: pointer; width: 180px"></select>
|
||||
<label for="renderer-select">Renderer: </label>
|
||||
<select id="renderer-select" style="cursor: pointer">
|
||||
<option value="default" selected>Default</option>
|
||||
<option value="canvas" selected>Canvas</option>
|
||||
<option value="svg">SVG</option>
|
||||
<option value="webgl">WebGL</option>
|
||||
</select>
|
||||
</div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
|
39
packages/g6/__tests__/integration/animation.spec.ts
Normal file
39
packages/g6/__tests__/integration/animation.spec.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import * as staticCases from '../demo/animation';
|
||||
import { createNodeGCanvas } from './utils/create-node-g-canvas';
|
||||
import { getCases } from './utils/get-cases';
|
||||
import { sleep } from './utils/sleep';
|
||||
import './utils/use-snapshot-matchers';
|
||||
|
||||
describe('static', () => {
|
||||
const cases = getCases(staticCases);
|
||||
|
||||
for (const [name, testCase] of cases) {
|
||||
it(`[animation]: ${name}`, async () => {
|
||||
const canvas = createNodeGCanvas();
|
||||
|
||||
try {
|
||||
const { times = [], preprocess, postprocess } = testCase;
|
||||
|
||||
await preprocess?.();
|
||||
const animationResult = await testCase({ canvas });
|
||||
animationResult.pause();
|
||||
|
||||
for (const time of times) {
|
||||
animationResult.currentTime = time;
|
||||
await sleep(20);
|
||||
await expect(canvas).toMatchSVGSnapshot(
|
||||
`${__dirname}/snapshots/animation`,
|
||||
// 命名示例:label-1000(1_3)
|
||||
// naming example: label-1000(1_3)
|
||||
`${name}-${time}(${times.indexOf(time) + 1}_${times.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
await postprocess?.();
|
||||
} finally {
|
||||
canvas.destroy();
|
||||
await sleep(50);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
@ -0,0 +1,57 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="500"
|
||||
height="500"
|
||||
style="background: transparent; position: absolute; outline: none;"
|
||||
color-interpolation-filters="sRGB"
|
||||
tabindex="1"
|
||||
>
|
||||
<defs />
|
||||
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
|
||||
<g
|
||||
id="g-root"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
visibility="visible"
|
||||
font-size="16px"
|
||||
font-family="sans-serif"
|
||||
font-style="normal"
|
||||
font-weight="normal"
|
||||
font-variant="normal"
|
||||
text-anchor="left"
|
||||
stroke-dashoffset="0px"
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
>
|
||||
<g
|
||||
id="g-svg-5"
|
||||
fill="none"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
font-size="20px"
|
||||
transform="matrix(1,0,0,1,50,50)"
|
||||
>
|
||||
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
|
||||
<path
|
||||
id="background"
|
||||
fill="none"
|
||||
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
width="100.19999999999999px"
|
||||
height="47px"
|
||||
/>
|
||||
</g>
|
||||
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
|
||||
<text
|
||||
id="text"
|
||||
fill="none"
|
||||
dominant-baseline="alphabetic"
|
||||
paint-order="stroke"
|
||||
dx="0.5"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
>
|
||||
label text
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,57 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="500"
|
||||
height="500"
|
||||
style="background: transparent; position: absolute; outline: none;"
|
||||
color-interpolation-filters="sRGB"
|
||||
tabindex="1"
|
||||
>
|
||||
<defs />
|
||||
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
|
||||
<g
|
||||
id="g-root"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
visibility="visible"
|
||||
font-size="16px"
|
||||
font-family="sans-serif"
|
||||
font-style="normal"
|
||||
font-weight="normal"
|
||||
font-variant="normal"
|
||||
text-anchor="left"
|
||||
stroke-dashoffset="0px"
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
>
|
||||
<g
|
||||
id="g-svg-5"
|
||||
fill="none"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
font-size="20px"
|
||||
transform="matrix(1,0,0,1,200,200)"
|
||||
>
|
||||
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
|
||||
<path
|
||||
id="background"
|
||||
fill="none"
|
||||
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
width="100.19999999999999px"
|
||||
height="47px"
|
||||
/>
|
||||
</g>
|
||||
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
|
||||
<text
|
||||
id="text"
|
||||
fill="none"
|
||||
dominant-baseline="alphabetic"
|
||||
paint-order="stroke"
|
||||
dx="0.5"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
>
|
||||
label text
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,57 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="500"
|
||||
height="500"
|
||||
style="background: transparent; position: absolute; outline: none;"
|
||||
color-interpolation-filters="sRGB"
|
||||
tabindex="1"
|
||||
>
|
||||
<defs />
|
||||
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
|
||||
<g
|
||||
id="g-root"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
visibility="visible"
|
||||
font-size="16px"
|
||||
font-family="sans-serif"
|
||||
font-style="normal"
|
||||
font-weight="normal"
|
||||
font-variant="normal"
|
||||
text-anchor="left"
|
||||
stroke-dashoffset="0px"
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
>
|
||||
<g
|
||||
id="g-svg-5"
|
||||
fill="none"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
font-size="20px"
|
||||
transform="matrix(1,0,0,1,50,50)"
|
||||
>
|
||||
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
|
||||
<path
|
||||
id="background"
|
||||
fill="none"
|
||||
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
width="100.19999999999999px"
|
||||
height="47px"
|
||||
/>
|
||||
</g>
|
||||
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
|
||||
<text
|
||||
id="text"
|
||||
fill="none"
|
||||
dominant-baseline="alphabetic"
|
||||
paint-order="stroke"
|
||||
dx="0.5"
|
||||
stroke="rgba(255,192,203,1)"
|
||||
>
|
||||
label text
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -13,17 +13,10 @@ describe('static', () => {
|
||||
|
||||
try {
|
||||
const { preprocess, postprocess } = testCase;
|
||||
|
||||
await preprocess?.();
|
||||
await testCase({
|
||||
container: document.getElementById('container')!,
|
||||
width: 500,
|
||||
height: 500,
|
||||
canvas,
|
||||
});
|
||||
await postprocess?.();
|
||||
|
||||
await testCase({ canvas });
|
||||
await expect(canvas).toMatchSVGSnapshot(`${__dirname}/snapshots/static`, name);
|
||||
await postprocess?.();
|
||||
} finally {
|
||||
canvas.destroy();
|
||||
await sleep(50);
|
||||
|
@ -1,12 +1,12 @@
|
||||
import type { TestCase } from '../../demo/types';
|
||||
import type { BaseTestCase } from '../../demo/types';
|
||||
|
||||
export function getCases(module: Record<string, TestCase>) {
|
||||
export function getCases<T extends BaseTestCase>(module: Record<string, T>) {
|
||||
const cases = Object.entries(module);
|
||||
const only = cases.filter(([, { only = false }]) => only);
|
||||
if (only.length !== 0 && process.env.CI === 'true') throw new Error('Cannot run only tests in CI');
|
||||
return (only.length !== 0 ? only : cases)
|
||||
.filter(([, { skip = false }]) => !skip)
|
||||
.map(([name, testCase]) => [kebabCase(name), testCase] as [string, TestCase]);
|
||||
.map(([name, testCase]) => [kebabCase(name), testCase] as [string, T]);
|
||||
}
|
||||
|
||||
const kebabCase = (str: string) => str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
||||
|
@ -0,0 +1,100 @@
|
||||
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
|
||||
import { Renderer as SVGRenderer } from '@antv/g-svg';
|
||||
import { Renderer as WebGLRenderer } from '@antv/g-webgl';
|
||||
import { Canvas } from '../src/runtime/canvas';
|
||||
import * as animations from './demo/animation';
|
||||
import * as statics from './demo/static';
|
||||
|
||||
type TestCase = (...args: unknown[]) => void;
|
||||
|
||||
const CASES = {
|
||||
statics,
|
||||
animations,
|
||||
} as unknown as { [key: string]: Record<string, TestCase> };
|
||||
|
||||
window.onload = () => {
|
||||
const casesSelect = document.getElementById('demo-select') as HTMLSelectElement;
|
||||
const rendererSelect = document.getElementById('renderer-select') as HTMLSelectElement;
|
||||
|
||||
function handleChange() {
|
||||
initialize();
|
||||
const [type, testCase] = casesSelect.value.split('-');
|
||||
const renderer = rendererSelect.value;
|
||||
setParamsToSearch({ type, case: testCase, renderer });
|
||||
onchange(CASES[type][testCase], renderer);
|
||||
}
|
||||
|
||||
casesSelect.onchange = handleChange;
|
||||
rendererSelect.onchange = handleChange;
|
||||
loadCasesList(casesSelect);
|
||||
getParamsFromSearch();
|
||||
handleChange();
|
||||
};
|
||||
|
||||
function loadCasesList(select: HTMLSelectElement) {
|
||||
Object.entries(CASES).forEach(([type, cases]) => {
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = type;
|
||||
select.appendChild(optgroup);
|
||||
Object.keys(cases).forEach((key) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = `${type}-${key}`;
|
||||
option.text = key;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onchange(testCase: TestCase, rendererName: string) {
|
||||
const renderer = getRenderer(rendererName);
|
||||
testCase({
|
||||
canvas: new Canvas({
|
||||
width: 500,
|
||||
height: 500,
|
||||
container: document.getElementById('container')!,
|
||||
renderer,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function getRenderer(rendererName: string) {
|
||||
switch (rendererName) {
|
||||
case 'webgl':
|
||||
return () => new WebGLRenderer();
|
||||
case 'svg':
|
||||
return () => new SVGRenderer();
|
||||
case 'canvas':
|
||||
return () => new CanvasRenderer();
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
document.getElementById('container')?.remove();
|
||||
const container = document.createElement('div');
|
||||
container.id = 'container';
|
||||
document.getElementById('app')?.appendChild(container);
|
||||
}
|
||||
|
||||
function getParamsFromSearch() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const type = searchParams.get('type') || 'statics';
|
||||
const testCase = searchParams.get('case') || Object.keys(statics)[0];
|
||||
const rendererName = searchParams.get('renderer') || 'canvas';
|
||||
|
||||
const casesSelect = document.getElementById('demo-select') as HTMLSelectElement;
|
||||
const rendererSelect = document.getElementById('renderer-select') as HTMLSelectElement;
|
||||
|
||||
casesSelect.value = `${type}-${testCase}`;
|
||||
rendererSelect.value = rendererName;
|
||||
}
|
||||
|
||||
function setParamsToSearch(options: { type: string; case: string; renderer: string }) {
|
||||
const { type, case: testCase, renderer } = options;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('type', type);
|
||||
searchParams.set('case', testCase);
|
||||
searchParams.set('renderer', renderer);
|
||||
window.history.replaceState(null, '', `?${searchParams.toString()}`);
|
||||
}
|
31
packages/g6/__tests__/unit/utils/animation.spec.ts
Normal file
31
packages/g6/__tests__/unit/utils/animation.spec.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { IAnimation } from '@antv/g';
|
||||
import { createAnimationsProxy } from '../../../src/utils/animation';
|
||||
|
||||
describe('animation', () => {
|
||||
it('createAnimationsProxy', () => {
|
||||
const sourcePause = jest.fn();
|
||||
const targetPause = jest.fn();
|
||||
const source = {
|
||||
currentTime: 0,
|
||||
pause: () => sourcePause(),
|
||||
} as IAnimation;
|
||||
const targets = [
|
||||
{ currentTime: 0, pause: () => targetPause() },
|
||||
{ currentTime: 0, pause: () => targetPause() },
|
||||
] as IAnimation[];
|
||||
|
||||
const proxy = createAnimationsProxy(source, targets);
|
||||
|
||||
expect(proxy.currentTime).toBe(0);
|
||||
proxy.currentTime = 100;
|
||||
|
||||
expect(source.currentTime).toBe(100);
|
||||
expect(targets[0].currentTime).toBe(100);
|
||||
expect(targets[1].currentTime).toBe(100);
|
||||
|
||||
proxy.pause();
|
||||
|
||||
expect(sourcePause).toHaveBeenCalledTimes(1);
|
||||
expect(targetPause).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
15
packages/g6/__tests__/unit/utils/padding.spec.ts
Normal file
15
packages/g6/__tests__/unit/utils/padding.spec.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { parsePadding } from '../../../src/utils/padding';
|
||||
|
||||
describe('padding', () => {
|
||||
it('parsePadding', () => {
|
||||
expect(parsePadding()).toEqual([0, 0, 0, 0]);
|
||||
|
||||
expect(parsePadding(10)).toEqual([10, 10, 10, 10]);
|
||||
|
||||
expect(parsePadding([10, 20])).toEqual([10, 20, 10, 20]);
|
||||
|
||||
expect(parsePadding([10, 20, 30])).toEqual([10, 20, 30, 20]);
|
||||
|
||||
expect(parsePadding([10, 20, 30, 40])).toEqual([10, 20, 30, 40]);
|
||||
});
|
||||
});
|
71
packages/g6/__tests__/unit/utils/prefix.spec.ts
Normal file
71
packages/g6/__tests__/unit/utils/prefix.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import {
|
||||
addPrefix,
|
||||
removePrefix,
|
||||
replacePrefix,
|
||||
startsWith,
|
||||
subStyleProps,
|
||||
superStyleProps,
|
||||
toLowercaseFirstLetter,
|
||||
toUppercaseFirstLetter,
|
||||
} from '../../../src/utils/prefix';
|
||||
|
||||
describe('prefix', () => {
|
||||
it('toUppercaseFirstLetter', () => {
|
||||
expect(toUppercaseFirstLetter('abc')).toBe('Abc');
|
||||
expect(toUppercaseFirstLetter('Abc')).toBe('Abc');
|
||||
expect(toUppercaseFirstLetter('')).toBe('');
|
||||
});
|
||||
|
||||
it('toLowercaseFirstLetter', () => {
|
||||
expect(toLowercaseFirstLetter('abc')).toBe('abc');
|
||||
expect(toLowercaseFirstLetter('Abc')).toBe('abc');
|
||||
expect(toLowercaseFirstLetter('')).toBe('');
|
||||
});
|
||||
|
||||
it('addPrefix', () => {
|
||||
expect(addPrefix('abc', 'prefix')).toBe('prefixAbc');
|
||||
expect(addPrefix('Abc', 'prefix')).toBe('prefixAbc');
|
||||
expect(addPrefix('', 'prefix')).toBe('prefix');
|
||||
});
|
||||
|
||||
it('removePrefix', () => {
|
||||
expect(removePrefix('prefixAbc', 'prefix')).toBe('abc');
|
||||
expect(removePrefix('prefixAbc', 'prefix', false)).toBe('Abc');
|
||||
expect(removePrefix('Abc', 'prefix')).toBe('Abc');
|
||||
expect(removePrefix('', 'prefix')).toBe('');
|
||||
expect(removePrefix('prefix', '')).toBe('prefix');
|
||||
expect(removePrefix('prefixAbc', '')).toBe('prefixAbc');
|
||||
});
|
||||
|
||||
it('startsWith', () => {
|
||||
expect(startsWith('prefixAbc', 'prefix')).toBe(true);
|
||||
expect(startsWith('prefixAbc', 'prefix')).toBe(true);
|
||||
expect(startsWith('Abc', 'prefix')).toBe(false);
|
||||
expect(startsWith('', 'prefix')).toBe(false);
|
||||
expect(startsWith('prefix', 'prefix')).toBe(false);
|
||||
});
|
||||
|
||||
it('subStyleProps', () => {
|
||||
expect(subStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ abc: 1, def: 2 });
|
||||
expect(subStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ abc: 1, def: 2 });
|
||||
expect(subStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({});
|
||||
expect(subStyleProps({}, 'prefix')).toEqual({});
|
||||
});
|
||||
|
||||
it('superStyleProps', () => {
|
||||
expect(superStyleProps({ abc: 1, def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
|
||||
expect(superStyleProps({ abc: 1, def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
|
||||
expect(superStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
|
||||
expect(superStyleProps({}, 'prefix')).toEqual({});
|
||||
});
|
||||
|
||||
it('replacePrefix', () => {
|
||||
expect(replacePrefix({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix', 'newPrefix')).toEqual({
|
||||
newPrefixAbc: 1,
|
||||
newPrefixDef: 2,
|
||||
Abc: 3,
|
||||
});
|
||||
expect(replacePrefix({ Abc: 1, Def: 2 }, 'prefix', 'newPrefix')).toEqual({ Abc: 1, Def: 2 });
|
||||
expect(replacePrefix({}, 'prefix', 'newPrefix')).toEqual({});
|
||||
});
|
||||
});
|
186
packages/g6/src/elements/shapes/base-shape.ts
Normal file
186
packages/g6/src/elements/shapes/base-shape.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import type { DisplayObject, DisplayObjectConfig, Group, GroupStyleProps, IAnimation } from '@antv/g';
|
||||
import { CustomElement } from '@antv/g';
|
||||
import { deepMix, isBoolean, isNil } from '@antv/util';
|
||||
import { createAnimationsProxy } from '../../utils/animation';
|
||||
|
||||
export interface BaseShapeStyleProps extends GroupStyleProps {}
|
||||
|
||||
export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomElement<T> {
|
||||
constructor(options: DisplayObjectConfig<T>) {
|
||||
super(options);
|
||||
|
||||
this.render(this.attributes as Required<T>, this);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 图形实例映射表
|
||||
*
|
||||
* <en> shape instance map
|
||||
*/
|
||||
protected shapeMap: Record<string, DisplayObject> = {};
|
||||
|
||||
/**
|
||||
* <zh> 动画实例映射表
|
||||
*
|
||||
* <en> animation instance map
|
||||
*/
|
||||
protected animateMap: Record<string, IAnimation> = {};
|
||||
|
||||
/**
|
||||
* <zh> 创建、更新或删除图形
|
||||
*
|
||||
* <en> create, update or remove shape
|
||||
* @param key - <zh> 图形名称 | <en> shape name
|
||||
* @param Ctor - <zh> 图形类型 | <en> shape type
|
||||
* @param style - <zh> 图形样式 | <en> shape style
|
||||
* @param container - <zh> 容器 | <en> container
|
||||
* @returns <zh> 图形实例 | <en> shape instance
|
||||
*/
|
||||
protected upsert<P extends DisplayObject>(
|
||||
key: string,
|
||||
Ctor: { new (...args: unknown[]): P },
|
||||
style: P['attributes'],
|
||||
container: DisplayObject,
|
||||
) {
|
||||
const target = this.shapeMap[key];
|
||||
// remove
|
||||
// 如果 style 为 false,则删除图形 / remove shape if style is false
|
||||
if (target && isBoolean(style) && !style) {
|
||||
container.removeChild(target);
|
||||
delete this.shapeMap[key];
|
||||
return;
|
||||
}
|
||||
|
||||
// create
|
||||
if (!target) {
|
||||
const instance = new Ctor({ style });
|
||||
instance.id = key;
|
||||
container.appendChild(instance);
|
||||
this.shapeMap[key] = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// update
|
||||
// 如果图形实例存在 update 方法,则调用 update 方法,否则调用 attr 方法
|
||||
// if shape instance has update method, call update method, otherwise call attr method
|
||||
if ('update' in target) (target.update as (...args: unknown[]) => unknown)(style);
|
||||
else target.attr(style);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 创建动画代理,将动画同步到子图形实例上
|
||||
*
|
||||
* <en> create animation proxy, synchronize animation to child shape instance
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = shape.animate(keyframes, options);
|
||||
* const proxy = shape.proxyAnimate(result);
|
||||
* ```
|
||||
* @param result - <zh> 主动画实例 | <en> main animation instance
|
||||
* @returns <zh> 动画代理 | <en> animation proxy
|
||||
*/
|
||||
protected proxyAnimate(result: IAnimation) {
|
||||
return createAnimationsProxy(result, Object.values(this.animateMap));
|
||||
}
|
||||
|
||||
public update(attr: Partial<T> = {}): void {
|
||||
this.attr(deepMix({}, this.attributes, attr));
|
||||
return this.render(this.attributes as Required<T>, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 在初始化时会被自动调用
|
||||
*
|
||||
* <en> will be called automatically when initializing
|
||||
* @param attributes
|
||||
* @param container
|
||||
*/
|
||||
public abstract render(attributes: Required<T>, container: Group): void;
|
||||
|
||||
public bindEvents() {}
|
||||
|
||||
/**
|
||||
* <zh/> 从给定的属性对象中提取图形样式属性。删除特定的属性,如位置、变换和类名
|
||||
*
|
||||
* <en/> Extracts the graphic style properties from a given attribute object.
|
||||
* Removes specific properties like position, transformation, and class name.
|
||||
* @param attributes - <zh/> 属性对象 | <en/> attribute object
|
||||
* @returns <zh/> 仅包含样式属性的对象 | <en/> An object containing only the style properties.
|
||||
*/
|
||||
public getGraphicStyle<T extends Record<string, any>>(
|
||||
attributes: T,
|
||||
): Omit<T, 'x' | 'y' | 'transform' | 'transformOrigin' | 'className'> {
|
||||
const { x, y, className, transform, transformOrigin, ...style } = attributes;
|
||||
return style;
|
||||
}
|
||||
|
||||
public animate(
|
||||
keyframes: PropertyIndexedKeyframes | Keyframe[],
|
||||
options?: number | KeyframeAnimationOptions,
|
||||
): IAnimation {
|
||||
if (!keyframes) return null;
|
||||
this.animateMap = {};
|
||||
|
||||
const result = super.animate(keyframes, options);
|
||||
|
||||
if (Array.isArray(keyframes) && keyframes.length > 0) {
|
||||
Object.entries(this.shapeMap).forEach(([key, shape]) => {
|
||||
// 如果存在方法名为 `get${key}Style` 的方法,则使用该方法获取样式,并自动为该图形实例创建动画
|
||||
// if there is a method named `get${key}Style`, use this method to get style and automatically create animation for the shape instance
|
||||
const methodName = `get${key[0].toUpperCase()}${key.slice(1)}Style`;
|
||||
|
||||
if (typeof this[methodName] === 'function') {
|
||||
const subKeyframes: Keyframe[] = keyframes.map((style) => this[methodName](style));
|
||||
|
||||
// 转化为 PropertyIndexedKeyframes 格式方便后续处理
|
||||
// convert to PropertyIndexedKeyframes format for subsequent processing
|
||||
const propertyIndexedKeyframes = subKeyframes.reduce(
|
||||
(acc, kf) => {
|
||||
Object.entries(kf).forEach(([key, value]) => {
|
||||
if (acc[key] === undefined) acc[key] = [value];
|
||||
else acc[key].push(value);
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any[]>,
|
||||
);
|
||||
|
||||
// 过滤掉无用动画的属性(属性值为 undefined、或者值完全一致)
|
||||
// filter out useless animation properties (property value is undefined, or value is exactly the same)
|
||||
Object.entries(propertyIndexedKeyframes).forEach(([key, values]) => {
|
||||
if (
|
||||
// 属性值必须在每一帧都存在 / property value must exist in every frame
|
||||
values.length !== subKeyframes.length ||
|
||||
// 属性值不能为空 / property value cannot be empty
|
||||
values.some((value) => isNil(value)) ||
|
||||
// 属性值必须不完全一致 / property value must not be exactly the same
|
||||
values.every((value) => value === values[0])
|
||||
) {
|
||||
delete propertyIndexedKeyframes[key];
|
||||
}
|
||||
});
|
||||
|
||||
this.animateMap[key] = shape.animate(
|
||||
// 将 PropertyIndexedKeyframes 转化为 Keyframe 格式
|
||||
// convert PropertyIndexedKeyframes to Keyframe format
|
||||
Object.entries(propertyIndexedKeyframes).reduce((acc, [key, values]) => {
|
||||
values.forEach((value, index) => {
|
||||
if (!acc[index]) acc[index] = { [key]: value };
|
||||
else acc[index][key] = value;
|
||||
});
|
||||
return acc;
|
||||
}, [] as Keyframe[]),
|
||||
options,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// TODO: support PropertyIndexedKeyframes
|
||||
}
|
||||
|
||||
return this.proxyAnimate(result);
|
||||
}
|
||||
}
|
81
packages/g6/src/elements/shapes/label.ts
Normal file
81
packages/g6/src/elements/shapes/label.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { DisplayObjectConfig, Group, Rect, RectStyleProps, Text, TextStyleProps } from '@antv/g';
|
||||
import { deepMix } from '@antv/util';
|
||||
import type { Padding } from '../../types/padding';
|
||||
import type { PrefixObject } from '../../types/prefix';
|
||||
import { parsePadding } from '../../utils/padding';
|
||||
import { omitStyleProps, startsWith, subStyleProps } from '../../utils/prefix';
|
||||
import type { BaseShapeStyleProps } from './base-shape';
|
||||
import { BaseShape } from './base-shape';
|
||||
|
||||
export type LabelStyleProps = BaseShapeStyleProps &
|
||||
TextStyleProps &
|
||||
PrefixObject<RectStyleProps, 'background'> & {
|
||||
padding?: Padding;
|
||||
};
|
||||
|
||||
type ParsedLabelStyleProps = Required<LabelStyleProps>;
|
||||
|
||||
export type LabelOptions = DisplayObjectConfig<LabelStyleProps>;
|
||||
|
||||
export class Label extends BaseShape<LabelStyleProps> {
|
||||
static defaultStyleProps: Partial<LabelStyleProps> = {
|
||||
padding: 5,
|
||||
backgroundZIndex: -1,
|
||||
};
|
||||
|
||||
constructor(options: LabelOptions) {
|
||||
super(deepMix({}, { style: Label.defaultStyleProps }, options));
|
||||
}
|
||||
|
||||
protected isTextStyle(key: string) {
|
||||
return startsWith(key, 'label');
|
||||
}
|
||||
|
||||
protected isBackgroundStyle(key: string) {
|
||||
return startsWith(key, 'background');
|
||||
}
|
||||
|
||||
protected getTextStyle(attributes: LabelStyleProps = this.attributes) {
|
||||
const { padding, ...style } = this.getGraphicStyle(attributes);
|
||||
return omitStyleProps<TextStyleProps>(style, 'background');
|
||||
}
|
||||
|
||||
protected getBackgroundStyle(attributes: LabelStyleProps = this.attributes) {
|
||||
const style = this.getGraphicStyle(attributes);
|
||||
const { wordWrapWidth, padding } = style;
|
||||
const backgroundStyle = subStyleProps<RectStyleProps>(style, 'background');
|
||||
|
||||
const {
|
||||
min: [minX, minY],
|
||||
halfExtents: [halfWidth, halfHeight],
|
||||
} = this.shapeMap.text.getLocalBounds();
|
||||
|
||||
const [top, right, bottom, left] = parsePadding(padding);
|
||||
|
||||
Object.assign(backgroundStyle, {
|
||||
x: minX - left,
|
||||
y: minY - top,
|
||||
width: (wordWrapWidth || halfWidth * 2) + left + right,
|
||||
height: halfHeight * 2 + top + bottom,
|
||||
});
|
||||
|
||||
// parse percentage radius
|
||||
const { radius } = backgroundStyle;
|
||||
// if radius look like '10%', convert it to number
|
||||
if (typeof radius === 'string' && radius.endsWith('%')) {
|
||||
const percentage = Number(radius.replace('%', '')) / 100;
|
||||
backgroundStyle.radius = Math.min(+backgroundStyle.width, +backgroundStyle.height) * percentage;
|
||||
}
|
||||
|
||||
return backgroundStyle;
|
||||
}
|
||||
|
||||
public render(attributes = this.attributes as ParsedLabelStyleProps, container: Group = this): void {
|
||||
this.upsert('text', Text, this.getTextStyle(attributes), container);
|
||||
this.upsert('background', Rect, this.getBackgroundStyle(attributes), container);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.upsert('background', Rect, this.getBackgroundStyle(this.attributes), this);
|
||||
}
|
||||
}
|
9
packages/g6/src/types/prefix.ts
Normal file
9
packages/g6/src/types/prefix.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type PrefixKey<P extends string = string, K extends string = string> = `${P}${Capitalize<K>}`;
|
||||
|
||||
export type PrefixObject<T extends object, P extends string> = {
|
||||
[K in keyof T as K extends string ? PrefixKey<P, K> : never]?: T[K];
|
||||
};
|
||||
|
||||
export type ReplacePrefix<T, OldPrefix extends string, NewPrefix extends string> = {
|
||||
[K in keyof T as K extends `${OldPrefix}${infer Rest}` ? `${NewPrefix}${Rest}` : K]: T[K];
|
||||
};
|
27
packages/g6/src/utils/animation.ts
Normal file
27
packages/g6/src/utils/animation.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { IAnimation } from '@antv/g';
|
||||
|
||||
/**
|
||||
* <zh/> 创建动画代理,对一个动画实例的操作同步到多个动画实例上
|
||||
*
|
||||
* <en/> create animation proxy, synchronize animation to multiple animation instances
|
||||
* @param sourceAnimation - <zh/> 源动画实例 | <en/> source animation instance
|
||||
* @param targetAnimations - <zh/> 目标动画实例 | <en/> target animation instance
|
||||
* @returns <zh/> 动画代理 | <en/> animation proxy
|
||||
*/
|
||||
export function createAnimationsProxy(sourceAnimation: IAnimation, targetAnimations: IAnimation[]): IAnimation {
|
||||
return new Proxy(sourceAnimation, {
|
||||
get(target, propKey) {
|
||||
if (typeof target[propKey] === 'function') {
|
||||
return (...args: unknown[]) => {
|
||||
target[propKey](...args);
|
||||
targetAnimations.forEach((animation) => animation[propKey]?.(...args));
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, propKey);
|
||||
},
|
||||
set(target, propKey, value) {
|
||||
targetAnimations.forEach((animation) => (animation[propKey] = value));
|
||||
return Reflect.set(target, propKey, value);
|
||||
},
|
||||
});
|
||||
}
|
16
packages/g6/src/utils/padding.ts
Normal file
16
packages/g6/src/utils/padding.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Padding, STDPadding } from '../types/padding';
|
||||
|
||||
/**
|
||||
* <zh> 解析 padding
|
||||
*
|
||||
* <en> parse padding
|
||||
* @param padding - <zh> padding | <en> padding
|
||||
* @returns <zh> 标准 padding | <en> standard padding
|
||||
*/
|
||||
export function parsePadding(padding: Padding = 0): STDPadding {
|
||||
if (Array.isArray(padding)) {
|
||||
const [top, right = top, bottom = top, left = right] = padding;
|
||||
return [top, right, bottom, left];
|
||||
}
|
||||
return [padding, padding, padding, padding];
|
||||
}
|
141
packages/g6/src/utils/prefix.ts
Normal file
141
packages/g6/src/utils/prefix.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { PrefixKey, PrefixObject, ReplacePrefix } from '../types/prefix';
|
||||
|
||||
/**
|
||||
* <zh> 首字母大写
|
||||
*
|
||||
* <en> Uppercase first letter
|
||||
* @param str - <zh> 字符串 | <en> string
|
||||
* @returns <zh> 首字母大写的字符串 | <en> Uppercase first letter string
|
||||
*/
|
||||
export function toUppercaseFirstLetter<T extends string>(str: T) {
|
||||
return (str.toString().charAt(0).toUpperCase() + str.toString().slice(1)) as Capitalize<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 首字母小写
|
||||
*
|
||||
* <en> Lowercase first letter
|
||||
* @param str - <zh> 字符串 | <en> string
|
||||
* @returns <zh> 首字母小写的字符串 | <en> Lowercase first letter string
|
||||
*/
|
||||
export function toLowercaseFirstLetter(str: string) {
|
||||
return str.toString().charAt(0).toLowerCase() + str.toString().slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 是否以某个前缀开头
|
||||
*
|
||||
* <en> Whether starts with prefix
|
||||
* @param str - <zh> 字符串 | <en> string
|
||||
* @param prefix - <zh> 前缀 | <en> prefix
|
||||
* @returns <zh> 是否以某个前缀开头 | <en> whether starts with prefix
|
||||
*/
|
||||
export function startsWith(str: string, prefix: string) {
|
||||
if (!str.startsWith(prefix)) return false;
|
||||
const nextChart = str[prefix.length];
|
||||
return nextChart >= 'A' && nextChart <= 'Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 添加前缀
|
||||
*
|
||||
* <en> Add prefix
|
||||
* @param str - <zh> 字符串 | <en> string
|
||||
* @param prefix - <zh> 前缀 | <en> prefix
|
||||
* @returns <zh> 添加前缀后的字符串 | <en> string with prefix
|
||||
*/
|
||||
export function addPrefix(str: string, prefix: string): PrefixKey {
|
||||
return `${prefix}${toUppercaseFirstLetter(str)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 移除前缀
|
||||
*
|
||||
* <en> Remove prefix
|
||||
* @param string - <zh> 字符串 | <en> string
|
||||
* @param prefix - <zh> 前缀 | <en> prefix
|
||||
* @param lowercaseFirstLetter - <zh> 是否小写首字母 | <en> whether lowercase first letter
|
||||
* @returns <zh> 移除前缀后的字符串 | <en> string without prefix
|
||||
*/
|
||||
export function removePrefix(string: string, prefix?: string, lowercaseFirstLetter: boolean = true) {
|
||||
if (!prefix) return string;
|
||||
if (!startsWith(string, prefix)) return string;
|
||||
const str = string.slice(prefix.length);
|
||||
return lowercaseFirstLetter ? toLowercaseFirstLetter(str) : str;
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 从样式中提取子样式
|
||||
*
|
||||
* <en> Extract sub style from style
|
||||
* @param style - <zh> 样式 | <en> style
|
||||
* @param prefix - <zh> 子样式前缀 | <en> sub style prefix
|
||||
* @returns <zh> 子样式 | <en> sub style
|
||||
*/
|
||||
export function subStyleProps<T extends object>(style: object, prefix: string) {
|
||||
return Object.entries(style).reduce((acc, [key, value]) => {
|
||||
if (key === 'className' || key === 'class') return acc;
|
||||
if (startsWith(key, prefix)) {
|
||||
acc[removePrefix(key, prefix)] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh/> 从样式中排除子样式
|
||||
*
|
||||
* <en/> Omit sub style from style
|
||||
* @param style - <zh/> 样式 | <en/> style
|
||||
* @param prefix - <zh/> 子样式前缀 | <en/> sub style prefix
|
||||
* @returns <zh/> 排除子样式后的样式 | <en/> style without sub style
|
||||
*/
|
||||
export function omitStyleProps<T extends object>(style: object, prefix: string) {
|
||||
return Object.entries(style).reduce((acc, [key, value]) => {
|
||||
if (!startsWith(key, prefix)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 创建前缀样式
|
||||
*
|
||||
* <en> Create prefix style
|
||||
* @param style - <zh> 样式 | <en> style
|
||||
* @param prefix - <zh> 前缀 | <en> prefix
|
||||
* @returns <zh> 前缀样式 | <en> prefix style
|
||||
*/
|
||||
export function superStyleProps<T extends object, P extends string>(style: T, prefix: P): PrefixObject<T, P> {
|
||||
return Object.entries(style).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[addPrefix(key, prefix)] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as PrefixObject<T, P>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* <zh> 替换前缀
|
||||
*
|
||||
* <en> Replace prefix
|
||||
* @param style - <zh> 样式 | <en> style
|
||||
* @param oldPrefix - <zh> 旧前缀 | <en> old prefix
|
||||
* @param newPrefix - <zh> 新前缀 | <en> new prefix
|
||||
* @returns <zh> 替换前缀后的样式 | <en> style with replaced prefix
|
||||
*/
|
||||
export function replacePrefix<T extends object>(style: T, oldPrefix: string, newPrefix: string) {
|
||||
return Object.entries(style).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (startsWith(key, oldPrefix)) {
|
||||
acc[addPrefix(removePrefix(key, oldPrefix, false), newPrefix)] = value;
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as ReplacePrefix<T, typeof oldPrefix, typeof newPrefix>,
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user