Skip to content

Commit c30aa55

Browse files
authored
feat: support dotted path syntax (#218)
* refactor: optimize home page streaming demo * feat(syntax): support dotted path syntax * refactor: export image support to remove background * fix: fix cr issue * chore: update version * fix: cr issues * fix: live editor copy image remove background
1 parent 15c788e commit c30aa55

14 files changed

Lines changed: 433 additions & 93 deletions

File tree

__tests__/unit/syntax/parse-syntax.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,37 @@ theme
459459
expect(result.options.themeConfig?.base?.shape?.fill).toBe('red');
460460
});
461461

462+
it('parses dotted key paths as nested objects', () => {
463+
const input = `
464+
theme.base.text.fill #fff
465+
theme.base.shape.fill red
466+
data
467+
items
468+
- label A
469+
`;
470+
const result = parseSyntax(input);
471+
expect(result.errors).toHaveLength(0);
472+
expect(result.options.themeConfig?.base?.text?.fill).toBe('#fff');
473+
expect(result.options.themeConfig?.base?.shape?.fill).toBe('red');
474+
});
475+
476+
it('supports mixing dotted keys with indented blocks', () => {
477+
const input = `
478+
theme
479+
base
480+
shape
481+
stroke #654321
482+
base.text.fill #123456
483+
data
484+
items
485+
- label A
486+
`;
487+
const result = parseSyntax(input);
488+
expect(result.errors).toHaveLength(0);
489+
expect(result.options.themeConfig?.base?.text?.fill).toBe('#123456');
490+
expect(result.options.themeConfig?.base?.shape?.stroke).toBe('#654321');
491+
});
492+
462493
it('parses template block shorthand and width string values', () => {
463494
const input = `
464495
template sales-dashboard
@@ -854,4 +885,73 @@ theme
854885
expect(result.options.data?.items?.[0]?.label).toBe('OK');
855886
expect(result.options.themeConfig?.colorBg).toBe('#000');
856887
});
888+
889+
it('reports bad_syntax when dotted keys traverse list nodes', () => {
890+
const input = `
891+
data
892+
items
893+
- label A
894+
data.items.label B
895+
theme
896+
colorBg #000
897+
`;
898+
const result = parseSyntax(input);
899+
expect(
900+
result.errors.some(
901+
(error) => error.code === 'bad_syntax' && error.path === 'data.items',
902+
),
903+
).toBe(true);
904+
expect(result.options.themeConfig?.colorBg).toBe('#000');
905+
});
906+
907+
it('reports bad_syntax for unsafe key parts in dotted paths', () => {
908+
const input = `
909+
data.__proto__.polluted true
910+
data.safe.constructor value
911+
theme
912+
colorBg #000
913+
`;
914+
const result = parseSyntax(input);
915+
expect(
916+
result.errors.some(
917+
(error) =>
918+
error.code === 'bad_syntax' &&
919+
error.path === 'data.__proto__.polluted',
920+
),
921+
).toBe(true);
922+
expect(
923+
result.errors.some(
924+
(error) =>
925+
error.code === 'bad_syntax' && error.path === 'data.safe.constructor',
926+
),
927+
).toBe(true);
928+
expect(result.options.themeConfig?.colorBg).toBe('#000');
929+
});
930+
931+
it('reports bad_syntax for unsafe non-dotted keys', () => {
932+
const input = `
933+
__proto__ hacked
934+
constructor hacked
935+
prototype hacked
936+
theme
937+
colorBg #000
938+
`;
939+
const result = parseSyntax(input);
940+
expect(
941+
result.errors.some(
942+
(error) => error.code === 'bad_syntax' && error.path === '__proto__',
943+
),
944+
).toBe(true);
945+
expect(
946+
result.errors.some(
947+
(error) => error.code === 'bad_syntax' && error.path === 'constructor',
948+
),
949+
).toBe(true);
950+
expect(
951+
result.errors.some(
952+
(error) => error.code === 'bad_syntax' && error.path === 'prototype',
953+
),
954+
).toBe(true);
955+
expect(result.options.themeConfig?.colorBg).toBe('#000');
956+
});
857957
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@antv/infographic",
3-
"version": "0.2.15",
3+
"version": "0.2.16",
44
"description": "An Infographic Generation and Rendering Framework, bring words to life!",
55
"keywords": [
66
"antv",

site/src/components/Infographic.tsx

Lines changed: 91 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,19 @@ import {
1010
useRef,
1111
} from 'react';
1212

13+
const downloadFile = (url: string, filename: string) => {
14+
const link = document.createElement('a');
15+
link.href = url;
16+
link.download = filename;
17+
document.body.appendChild(link);
18+
link.click();
19+
document.body.removeChild(link);
20+
};
21+
1322
export type InfographicHandle = {
14-
copyToClipboard: () => Promise<boolean>;
15-
exportPNG: () => Promise<void>;
16-
exportSVG: () => Promise<void>;
23+
copyToClipboard: (options?: {removeBackground?: boolean}) => Promise<boolean>;
24+
exportPNG: (options?: {removeBackground?: boolean}) => Promise<void>;
25+
exportSVG: (options?: {removeBackground?: boolean}) => Promise<void>;
1726
};
1827

1928
export const Infographic = forwardRef<
@@ -80,39 +89,49 @@ export const Infographic = forwardRef<
8089
};
8190
}, []);
8291

83-
const handleCopy = useCallback(async () => {
84-
const instance = instanceRef.current;
85-
if (!instance) {
86-
return false;
87-
}
88-
89-
try {
90-
const dataUrl = await instance.toDataURL();
91-
if (!dataUrl) {
92+
const handleCopy = useCallback(
93+
async (options?: {removeBackground?: boolean}) => {
94+
const instance = instanceRef.current;
95+
if (!instance) {
9296
return false;
9397
}
9498

95-
const clipboard = navigator?.clipboard;
96-
if (!clipboard) {
97-
return false;
98-
}
99+
try {
100+
const dataUrl = await instance.toDataURL({
101+
type: 'png',
102+
removeBackground: options?.removeBackground ?? false,
103+
});
104+
if (!dataUrl) {
105+
return false;
106+
}
107+
108+
const clipboard = navigator?.clipboard;
109+
if (!clipboard) {
110+
return false;
111+
}
112+
113+
if ('write' in clipboard && typeof ClipboardItem !== 'undefined') {
114+
const res = await fetch(dataUrl);
115+
const blob = await res.blob();
116+
await clipboard.write([new ClipboardItem({[blob.type]: blob})]);
117+
} else if ('writeText' in clipboard) {
118+
await clipboard.writeText(dataUrl);
119+
} else {
120+
return false;
121+
}
99122

100-
if ('write' in clipboard && typeof ClipboardItem !== 'undefined') {
101-
const res = await fetch(dataUrl);
102-
const blob = await res.blob();
103-
await clipboard.write([new ClipboardItem({[blob.type]: blob})]);
104-
} else if ('writeText' in clipboard) {
105-
await clipboard.writeText(dataUrl);
106-
} else {
123+
return true;
124+
} catch (e) {
125+
console.error('Infographic copy error', e);
107126
return false;
108127
}
128+
},
129+
[]
130+
);
109131

110-
return true;
111-
} catch (e) {
112-
console.error('Infographic copy error', e);
113-
return false;
114-
}
115-
}, []);
132+
const handleDoubleClick = useCallback(() => {
133+
void handleCopy();
134+
}, [handleCopy]);
116135

117136
const getFilename = useCallback((extension: string) => {
118137
const instance = instanceRef.current;
@@ -133,49 +152,49 @@ export const Infographic = forwardRef<
133152
return `infographic-${Date.now()}.${extension}`;
134153
}, []);
135154

136-
const handleExportPNG = useCallback(async () => {
137-
const instance = instanceRef.current;
138-
if (!instance) return;
139-
140-
try {
141-
const dataUrl = await instance.toDataURL();
142-
if (!dataUrl) return;
143-
144-
// Create download link
145-
const link = document.createElement('a');
146-
link.href = dataUrl;
147-
link.download = getFilename('png');
148-
document.body.appendChild(link);
149-
link.click();
150-
document.body.removeChild(link);
151-
} catch (e) {
152-
console.error('PNG export error', e);
153-
}
154-
}, [getFilename]);
155-
156-
const handleExportSVG = useCallback(async () => {
157-
const instance = instanceRef.current;
158-
if (!instance) return;
155+
const handleExportPNG = useCallback(
156+
async (options?: {removeBackground?: boolean}) => {
157+
const instance = instanceRef.current;
158+
if (!instance) return;
159+
160+
try {
161+
const dataUrl = await instance.toDataURL({
162+
type: 'png',
163+
removeBackground: options?.removeBackground ?? false,
164+
});
165+
if (!dataUrl) return;
166+
downloadFile(dataUrl, getFilename('png'));
167+
} catch (e) {
168+
console.error('PNG export error', e);
169+
}
170+
},
171+
[getFilename]
172+
);
159173

160-
try {
161-
const svgDataUrl = await instance.toDataURL({type: 'svg'});
162-
if (!svgDataUrl) return;
163-
164-
// Convert data URL to blob
165-
const response = await fetch(svgDataUrl);
166-
const blob = await response.blob();
167-
const url = URL.createObjectURL(blob);
168-
const link = document.createElement('a');
169-
link.href = url;
170-
link.download = getFilename('svg');
171-
document.body.appendChild(link);
172-
link.click();
173-
document.body.removeChild(link);
174-
URL.revokeObjectURL(url);
175-
} catch (e) {
176-
console.error('SVG export error', e);
177-
}
178-
}, [getFilename]);
174+
const handleExportSVG = useCallback(
175+
async (options?: {removeBackground?: boolean}) => {
176+
const instance = instanceRef.current;
177+
if (!instance) return;
178+
179+
try {
180+
const svgDataUrl = await instance.toDataURL({
181+
type: 'svg',
182+
removeBackground: options?.removeBackground ?? false,
183+
});
184+
if (!svgDataUrl) return;
185+
186+
// Convert data URL to blob
187+
const response = await fetch(svgDataUrl);
188+
const blob = await response.blob();
189+
const url = URL.createObjectURL(blob);
190+
downloadFile(url, getFilename('svg'));
191+
URL.revokeObjectURL(url);
192+
} catch (e) {
193+
console.error('SVG export error', e);
194+
}
195+
},
196+
[getFilename]
197+
);
179198

180199
useImperativeHandle(
181200
ref,
@@ -191,7 +210,7 @@ export const Infographic = forwardRef<
191210
<div
192211
className={['w-full h-full', className].filter(Boolean).join(' ')}
193212
ref={containerRef}
194-
onDoubleClick={handleCopy}
213+
onDoubleClick={handleDoubleClick}
195214
style={style}
196215
/>
197216
);

site/src/components/Layout/HomePage/StreamingSyntaxShowcase.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {useInView} from 'framer-motion';
22
import NextLink from 'next/link';
33
import {CSSProperties, useCallback, useEffect, useRef, useState} from 'react';
44
import {useFullscreen} from '../../../hooks/useFullscreen';
5+
import {useTheme} from '../../../hooks/useTheme';
56
import {useLocaleBundle} from '../../../hooks/useTranslation';
67

78
import {Infographic} from '../../Infographic';
@@ -321,6 +322,8 @@ export function StreamingSyntaxShowcase({
321322
highlights,
322323
}: StreamingSyntaxShowcaseProps) {
323324
const translation = useLocaleBundle(TRANSLATIONS);
325+
const theme = useTheme();
326+
const isDark = theme === 'dark';
324327
const [caseType, setCaseType] = useState<'timeline' | 'mindmap'>('timeline');
325328
const FULL_STREAM_TEXT = translation[caseType].syntax;
326329

@@ -333,6 +336,7 @@ export function StreamingSyntaxShowcase({
333336
const [renderError, setRenderError] = useState<string | null>(null);
334337
const [isStreaming, setIsStreaming] = useState(false);
335338
const [isPaused, setIsPaused] = useState(false);
339+
const [previewKey, setPreviewKey] = useState(0);
336340

337341
// 自定义全屏布局计算
338342
const calculateFullscreenLayout = useCallback(
@@ -399,6 +403,7 @@ export function StreamingSyntaxShowcase({
399403
if (progressRef.current === 0) {
400404
setDisplayCode('');
401405
setRenderError(null);
406+
setPreviewKey((prev) => prev + 1);
402407
}
403408

404409
setIsStreaming(true);
@@ -664,8 +669,14 @@ export function StreamingSyntaxShowcase({
664669
</div>
665670
) : null}
666671
<Infographic
672+
key={previewKey}
667673
options={displayCode}
668-
init={{width: 720, height: 480, padding: 24}}
674+
init={{
675+
width: 720,
676+
height: 480,
677+
padding: 24,
678+
...(isDark ? {theme: 'dark'} : {}),
679+
}}
669680
onError={(err) => setRenderError(err ? err.message : null)}
670681
/>
671682
</div>

0 commit comments

Comments
 (0)